Skip to main content

devflow_core/
monitor.rs

1//! Background monitor daemon.
2//!
3//! Spawns a detached child process that *owns* the coding agent: it launches
4//! the agent, captures its stdout and exit code into `.devflow/`, and — when
5//! the agent exits — runs `devflow advance` to advance the stage machine.
6//!
7//! Owning the agent is the key fix over a CLI-scoped capture thread: because
8//! the monitor outlives `devflow start`, the agent's stdout keeps flowing into
9//! the capture file and its exit code is still reaped after the CLI exits.
10//!
11//! This is the core automation primitive — no cron, no scheduler,
12//! no agent cooperation needed.
13
14use crate::state::State;
15use std::path::Path;
16use std::process::{Command, Stdio};
17use std::time::Duration;
18use tracing::{debug, info};
19
20/// Errors produced by monitor operations.
21#[derive(Debug, thiserror::Error)]
22pub enum MonitorError {
23    /// Spawning the monitor process failed.
24    #[error("failed to spawn monitor: {0}")]
25    Io(#[from] std::io::Error),
26    /// Project path is not valid UTF-8.
27    #[error("project path is not valid UTF-8")]
28    NonUtf8Path,
29    /// Could not determine the current executable path.
30    #[error("could not determine devflow binary path")]
31    NoBinaryPath,
32}
33
34/// Spawn a background monitor that owns the agent for the given workflow state.
35///
36/// The monitor is a detached shell process that:
37/// 1. Launches the agent (`program` + `args`) with stdout redirected to the
38///    phase stdout file, recording the agent PID to the agent-pid file
39/// 2. Waits for the agent to exit and records its exit code to the exit file
40/// 3. Runs `devflow advance --phase N` to advance the workflow through its
41///    remaining stages
42///
43/// Returns the PID of the spawned monitor.
44pub fn spawn_monitor(
45    state: &State,
46    program: &str,
47    args: &[String],
48    envs: &[(String, String)],
49) -> Result<u32, MonitorError> {
50    spawn_monitor_inner(state, program, args, envs, true)
51}
52
53fn spawn_monitor_inner(
54    state: &State,
55    program: &str,
56    args: &[String],
57    envs: &[(String, String)],
58    run_advance: bool,
59) -> Result<u32, MonitorError> {
60    let project_root = state
61        .project_root
62        .to_str()
63        .ok_or(MonitorError::NonUtf8Path)?;
64
65    let binary = std::env::current_exe()
66        .map_err(|_| MonitorError::NoBinaryPath)?
67        .to_str()
68        .ok_or(MonitorError::NonUtf8Path)?
69        .to_string();
70
71    info!(
72        "spawning monitor for phase {}: {program} {}",
73        state.phase,
74        args.join(" ")
75    );
76
77    let stdout_file = crate::agent_result::stdout_path(&state.project_root, state.phase);
78    let stderr_file = crate::agent_result::stderr_path(&state.project_root, state.phase);
79    let exit_file = crate::agent_result::exit_code_path(&state.project_root, state.phase);
80    let pid_file = crate::agent_result::agent_pid_path(&state.project_root, state.phase);
81
82    // Ensure the capture directory exists before the detached process runs.
83    if let Some(parent) = stdout_file.parent() {
84        crate::workflow::ensure_devflow_dir(parent)?;
85    }
86
87    let stdout_file = stdout_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
88    let stderr_file = stderr_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
89    let exit_file = exit_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
90    let pid_file = pid_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
91
92    // The agent runs in its worktree when worktree mode is active; otherwise it
93    // runs in the project root. Capture/state files and the `devflow check`
94    // calls below always use the main project root, regardless of cwd.
95    let workdir = state
96        .worktree_path
97        .as_deref()
98        .unwrap_or(&state.project_root)
99        .to_str()
100        .ok_or(MonitorError::NonUtf8Path)?;
101
102    // Shell script that launches the agent in the background, captures its
103    // stdout and exit code, then advances the workflow. Because this process
104    // is the agent's parent, capture survives the CLI exiting.
105    //
106    // stderr is captured to a separate file so it cannot corrupt the (possibly
107    // JSON) stdout capture that DevFlow parses for DEVFLOW_RESULT. Inspect
108    // .devflow/phase-NN-stderr.log for agent error output on failures.
109    //
110    // `devflow advance --phase N` evaluates the agent result, moves the stage
111    // machine forward, and (for an agent stage) spawns the next monitor
112    // itself. The phase is recorded here at spawn time so advance's identity
113    // never depends on a shared state singleton (13-DEFERRED-CR-03): under
114    // `devflow parallel`, each phase's monitor advances exactly its own
115    // stage machine.
116    //
117    // Traps SIGTERM and SIGINT for clean shutdown. WR-08 (13-REVIEW.md):
118    // the trap must also kill the backgrounded agent ($apid) — previously
119    // it only exited the monitor shell itself, orphaning the agent so it
120    // kept running/committing unsupervised with nothing left to call
121    // `devflow advance` once it finished. `apid` is initialized to empty
122    // before the trap is installed so a signal arriving before the agent is
123    // even backgrounded doesn't reference an unset variable.
124    let advance_tail = if run_advance {
125        format!(
126            "; {binary} advance {project_root} --phase {phase}",
127            binary = shell_escape(&binary),
128            project_root = shell_escape(project_root),
129            phase = state.phase,
130        )
131    } else {
132        String::new()
133    };
134    let script = format!(
135        "apid=''; cleanup() {{ [ -n \"$apid\" ] && kill \"$apid\" 2>/dev/null; exit 0; }}; \
136         trap cleanup TERM INT; \
137         cd {workdir} || exit 1; \
138         \"$@\" > {stdout_file} 2>{stderr_file} & \
139         apid=$!; echo $apid > {pid_file}; \
140         wait $apid; echo $? > {exit_file}{advance_tail}",
141        workdir = shell_escape(workdir),
142        stdout_file = shell_escape(stdout_file),
143        stderr_file = shell_escape(stderr_file),
144        exit_file = shell_escape(exit_file),
145        pid_file = shell_escape(pid_file),
146    );
147
148    let child = Command::new("sh")
149        .arg("-c")
150        .arg(&script)
151        .arg("sh")
152        .arg(program)
153        .args(args)
154        // Adapter-scoped env (e.g. Codex's unsigned-commit override) rides
155        // the whole monitor chain: sh → agent → its git children (13-06).
156        .envs(envs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
157        .stdin(Stdio::null())
158        .stdout(Stdio::null())
159        .stderr(Stdio::null())
160        .spawn()?;
161
162    let pid = child.id();
163    info!("monitor spawned with pid {pid}");
164    Ok(pid)
165}
166
167/// Poll for the agent PID that the monitor records, for up to ~1 second.
168///
169/// Returns the PID once the monitor has launched the agent, or `None` if it
170/// does not appear in time (the monitor still runs; only the display PID is lost).
171pub fn wait_for_agent_pid(project_root: &Path, phase: u32) -> Option<u32> {
172    let path = crate::agent_result::agent_pid_path(project_root, phase);
173    debug!("polling for agent PID for phase {phase}");
174    for _ in 0..50 {
175        if let Ok(contents) = std::fs::read_to_string(&path)
176            && let Ok(pid) = contents.trim().parse::<u32>()
177        {
178            return Some(pid);
179        }
180        std::thread::sleep(Duration::from_millis(20));
181    }
182    debug!("agent PID not found for phase {phase} after polling");
183    None
184}
185
186/// Escape a string for safe use in a single-quoted shell context.
187fn shell_escape(s: &str) -> String {
188    format!("'{}'", s.replace('\'', "'\\''"))
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::mode::Mode;
195    use crate::stage::Stage;
196    use crate::state::{AgentKind, State};
197
198    fn state_in(root: &Path) -> State {
199        let mut state = State::new(4, AgentKind::Claude, Mode::Auto, root.to_path_buf());
200        state.stage = Stage::Code;
201        state
202    }
203
204    #[test]
205    fn shell_escape_wraps_basic_strings() {
206        assert_eq!(shell_escape("hello"), "'hello'");
207        assert_eq!(shell_escape("hello world"), "'hello world'");
208        assert_eq!(shell_escape("/tmp/devflow"), "'/tmp/devflow'");
209    }
210
211    #[test]
212    fn shell_escape_handles_single_quotes() {
213        assert_eq!(shell_escape("can't"), "'can'\\''t'");
214        assert_eq!(shell_escape("a'b'c"), "'a'\\''b'\\''c'");
215    }
216
217    #[test]
218    fn shell_escape_handles_empty_string() {
219        assert_eq!(shell_escape(""), "''");
220    }
221
222    #[test]
223    fn wait_for_agent_pid_returns_pid_when_file_exists() {
224        let dir = tempfile::tempdir().unwrap();
225        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
226        std::fs::write(
227            crate::agent_result::agent_pid_path(dir.path(), 4),
228            "12345\n",
229        )
230        .unwrap();
231
232        assert_eq!(wait_for_agent_pid(dir.path(), 4), Some(12345));
233    }
234
235    #[test]
236    fn wait_for_agent_pid_returns_none_when_file_missing() {
237        let dir = tempfile::tempdir().unwrap();
238
239        assert_eq!(wait_for_agent_pid(dir.path(), 4), None);
240    }
241
242    #[test]
243    fn wait_for_agent_pid_returns_none_for_garbage_content() {
244        let dir = tempfile::tempdir().unwrap();
245        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
246        std::fs::write(
247            crate::agent_result::agent_pid_path(dir.path(), 4),
248            "not-a-pid",
249        )
250        .unwrap();
251
252        assert_eq!(wait_for_agent_pid(dir.path(), 4), None);
253    }
254
255    #[test]
256    fn spawn_monitor_captures_agent_pid_and_output() {
257        let dir = tempfile::tempdir().unwrap();
258        let state = state_in(dir.path());
259        // Stub agent: write a known marker to stdout, then exit cleanly.
260        let args = vec!["-c".to_string(), "echo MONITOR_READY".to_string()];
261
262        let monitor_pid = spawn_monitor(&state, "sh", &args, &[]).unwrap();
263        assert!(monitor_pid > 0);
264
265        // Observable side effect #1: the monitor records the agent PID to its
266        // pid file with valid numeric content.
267        let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
268            .expect("monitor should record the agent pid");
269        assert!(agent_pid > 0);
270
271        // Observable side effect #2: the agent's stdout is captured to the
272        // phase stdout file (proving the monitor actually ran the agent).
273        let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
274        let mut captured = String::new();
275        for _ in 0..100 {
276            if let Ok(contents) = std::fs::read_to_string(&stdout_path)
277                && contents.contains("MONITOR_READY")
278            {
279                captured = contents;
280                break;
281            }
282            std::thread::sleep(Duration::from_millis(20));
283        }
284        assert!(
285            captured.contains("MONITOR_READY"),
286            "expected MONITOR_READY in captured stdout, got {captured:?}"
287        );
288    }
289
290    /// WR-08 (13-REVIEW.md): sending SIGTERM/SIGINT to the monitor must also
291    /// terminate the agent it owns. Before the fix, `cleanup()` only exited
292    /// the monitor shell, leaving the agent orphaned and running/committing
293    /// unsupervised with nothing left to call `devflow advance` for it.
294    /// A one-line identity/state summary of a pid, for failure diagnostics.
295    /// `Name`/`State`/`PPid` come from `/proc/<pid>/status`; the cmdline
296    /// distinguishes a shell that exec'd its command from one that forked it.
297    /// Test-only; never used in a decision.
298    fn proc_snapshot(pid: u32) -> String {
299        let Ok(status) = std::fs::read_to_string(format!("/proc/{pid}/status")) else {
300            return format!("GONE (no /proc/{pid})");
301        };
302        let field = |key: &str| {
303            status
304                .lines()
305                .find(|l| l.starts_with(key))
306                .map(|l| l.split_whitespace().skip(1).collect::<Vec<_>>().join(" "))
307                .unwrap_or_else(|| "?".into())
308        };
309        let cmdline = std::fs::read(format!("/proc/{pid}/cmdline"))
310            .map(|raw| {
311                let joined = raw
312                    .split(|&b| b == 0)
313                    .filter(|a| !a.is_empty())
314                    .map(|a| String::from_utf8_lossy(a).into_owned())
315                    .collect::<Vec<_>>()
316                    .join(" ");
317                if joined.is_empty() {
318                    "<empty>".to_string()
319                } else {
320                    joined
321                }
322            })
323            .unwrap_or_else(|e| format!("<unreadable: {e}>"));
324        format!(
325            "ALIVE Name={} State={} PPid={} cmdline=[{cmdline}]",
326            field("Name:"),
327            field("State:"),
328            field("PPid:")
329        )
330    }
331
332    #[test]
333    fn sigterm_to_monitor_also_kills_the_agent() {
334        let dir = tempfile::tempdir().unwrap();
335        let state = state_in(dir.path());
336        // Stub agent that runs long enough to observe: sleeps well past the
337        // window this test needs to send SIGTERM and check liveness.
338        let args = vec!["-c".to_string(), "sleep 30".to_string()];
339
340        let monitor_pid = spawn_monitor(&state, "sh", &args, &[]).unwrap();
341        let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
342            .expect("monitor should record the agent pid");
343        assert!(
344            crate::agent::agent_running(agent_pid),
345            "agent should be running before SIGTERM"
346        );
347
348        // Snapshot both processes before signalling. This assertion fails in
349        // containerised CI and cannot be reproduced locally, and a bare
350        // "still running" message discards everything that could explain it
351        // — the same antipattern that made 999.47 expensive to diagnose.
352        let monitor_before = proc_snapshot(monitor_pid);
353        let agent_before = proc_snapshot(agent_pid);
354
355        // SIGTERM the monitor, as an operator (or lock.rs's stale-holder
356        // reclaim path) would to abort a run.
357        let kill_rc = unsafe { libc::kill(monitor_pid as libc::pid_t, libc::SIGTERM) };
358        let kill_err = if kill_rc == 0 {
359            "ok".to_string()
360        } else {
361            format!("errno {}", std::io::Error::last_os_error())
362        };
363
364        // The agent should be killed promptly by the monitor's trap —
365        // poll rather than sleep a fixed amount to keep this fast and
366        // avoid flaking under load. (Window widened to 5s: at 2s this
367        // still flaked under a fully parallel workspace test run.)
368        //
369        // 2026-07-26: this was widened 5s -> 15s for the containerised CI
370        // job and STILL failed, then reverted to 5s. That widening was a
371        // mistake: 15s is far beyond any plausible trap-and-kill latency,
372        // so the agent is not being reaped SLOWLY, it is not being reaped.
373        // Buying silence with a bigger number would have hidden a real
374        // defect behind a green check — the exact false negative this
375        // repository keeps getting bitten by.
376        //
377        // The trap mechanism itself is verified working: DevFlow's real
378        // monitor script shape was run under both `bash` and `dash` (the
379        // container's /bin/sh is dash, the Fedora host's is bash) and both
380        // killed the backgrounded agent correctly. So the defect is in how
381        // the agent is spawned or identified under container timing, not in
382        // the shell trap — see 999.47, whose confirmed transient fork/exec
383        // window is the prime suspect for the same class of failure here.
384        //
385        // Leave this red until that is fixed. Do NOT widen it again.
386        let mut still_running = true;
387        for _ in 0..250 {
388            if !crate::agent::agent_running(agent_pid) {
389                still_running = false;
390                break;
391            }
392            std::thread::sleep(Duration::from_millis(20));
393        }
394        let monitor_after = proc_snapshot(monitor_pid);
395        let agent_after = proc_snapshot(agent_pid);
396        let pidfile =
397            std::fs::read_to_string(crate::agent_result::agent_pid_path(dir.path(), state.phase))
398                .unwrap_or_else(|e| format!("<unreadable: {e}>"));
399
400        assert!(
401            !still_running,
402            "agent (pid {agent_pid}) was orphaned — still running after monitor SIGTERM\n\
403             \x20 monitor pid:      {monitor_pid}\n\
404             \x20 kill(TERM) rc:    {kill_rc} ({kill_err})\n\
405             \x20 monitor before:   {monitor_before}\n\
406             \x20 monitor after:    {monitor_after}\n\
407             \x20 agent pid:        {agent_pid}\n\
408             \x20 agent before:     {agent_before}\n\
409             \x20 agent after:      {agent_after}\n\
410             \x20 pidfile contents: {}\n\
411             Read the monitor's `after` line first. GONE means the shell died \
412             without running its trap — most likely SIGTERM arrived before \
413             `trap` was installed, or it was killed rather than handling the \
414             signal, either way leaving the agent unreaped. STILL ALIVE means \
415             the trap never fired or `kill $apid` failed, so compare the agent \
416             pid against the pidfile and check the agent's PPid: if PPid is not \
417             the monitor, `$!` did not name the process we are polling. If the \
418             agent's Name is `sh` rather than `sleep`, the agent shell forked \
419             rather than exec'd, so killing it leaves its own child behind.",
420            pidfile.trim()
421        );
422    }
423
424    #[test]
425    fn spawn_monitor_runs_agent_in_worktree_but_captures_in_project_root() {
426        let dir = tempfile::tempdir().unwrap();
427        let worktree = dir.path().join(".worktrees/phase-04");
428        std::fs::create_dir_all(&worktree).unwrap();
429        let mut state = state_in(dir.path());
430        state.worktree_path = Some(worktree.clone());
431
432        // Stub agent: print its cwd so the test proves the monitor changed
433        // directories before launching the agent.
434        let args = vec!["-c".to_string(), "pwd; echo WORKTREE_READY".to_string()];
435
436        let monitor_pid = spawn_monitor(&state, "sh", &args, &[]).unwrap();
437        assert!(monitor_pid > 0);
438
439        let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
440            .expect("monitor should record the agent pid in the main project");
441        assert!(agent_pid > 0);
442
443        let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
444        let mut captured = String::new();
445        for _ in 0..100 {
446            if let Ok(contents) = std::fs::read_to_string(&stdout_path)
447                && contents.contains("WORKTREE_READY")
448            {
449                captured = contents;
450                break;
451            }
452            std::thread::sleep(Duration::from_millis(20));
453        }
454
455        assert!(
456            captured.contains(&worktree.display().to_string()),
457            "agent did not run in worktree cwd; captured stdout: {captured:?}"
458        );
459        assert!(
460            stdout_path.exists(),
461            "stdout capture missing in main .devflow"
462        );
463        assert!(
464            !crate::agent_result::stdout_path(&worktree, state.phase).exists(),
465            "stdout capture should not be written under the worktree"
466        );
467    }
468
469    #[test]
470    fn spawn_monitor_treats_agent_args_as_literal_argv() {
471        let dir = tempfile::tempdir().unwrap();
472        let state = state_in(dir.path());
473        let payload = "value; touch INJECTED";
474        let args = vec![
475            "-c".to_string(),
476            "printf '%s\\n' \"$0\"; echo ARGV_SAFE".to_string(),
477            payload.to_string(),
478        ];
479
480        spawn_monitor(&state, "sh", &args, &[]).unwrap();
481        wait_for_agent_pid(dir.path(), state.phase).expect("monitor should record the agent pid");
482
483        let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
484        let mut captured = String::new();
485        for _ in 0..100 {
486            if let Ok(contents) = std::fs::read_to_string(&stdout_path)
487                && contents.contains("ARGV_SAFE")
488            {
489                captured = contents;
490                break;
491            }
492            std::thread::sleep(Duration::from_millis(20));
493        }
494
495        assert!(
496            captured.contains(payload),
497            "literal argv missing: {captured:?}"
498        );
499        assert!(captured.contains("ARGV_SAFE"));
500        assert!(!dir.path().join("INJECTED").exists());
501    }
502}