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
53/// Spawn a monitor that owns the agent and records its capture files but does
54/// NOT advance the stage machine when the agent exits. Used by `sequentagent`,
55/// which drives its own synchronous handoff loop: the CLI blocks on the exit
56/// file (see [`wait_for_agent_exit`]) while the monitor guarantees capture
57/// survives even if the CLI dies.
58pub fn spawn_monitor_no_advance(
59    state: &State,
60    program: &str,
61    args: &[String],
62    envs: &[(String, String)],
63) -> Result<u32, MonitorError> {
64    spawn_monitor_inner(state, program, args, envs, false)
65}
66
67fn spawn_monitor_inner(
68    state: &State,
69    program: &str,
70    args: &[String],
71    envs: &[(String, String)],
72    run_advance: bool,
73) -> Result<u32, MonitorError> {
74    let project_root = state
75        .project_root
76        .to_str()
77        .ok_or(MonitorError::NonUtf8Path)?;
78
79    let binary = std::env::current_exe()
80        .map_err(|_| MonitorError::NoBinaryPath)?
81        .to_str()
82        .ok_or(MonitorError::NonUtf8Path)?
83        .to_string();
84
85    info!(
86        "spawning monitor for phase {}: {program} {}",
87        state.phase,
88        args.join(" ")
89    );
90
91    let stdout_file = crate::agent_result::stdout_path(&state.project_root, state.phase);
92    let stderr_file = crate::agent_result::stderr_path(&state.project_root, state.phase);
93    let exit_file = crate::agent_result::exit_code_path(&state.project_root, state.phase);
94    let pid_file = crate::agent_result::agent_pid_path(&state.project_root, state.phase);
95
96    // Ensure the capture directory exists before the detached process runs.
97    if let Some(parent) = stdout_file.parent() {
98        std::fs::create_dir_all(parent)?;
99    }
100
101    let stdout_file = stdout_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
102    let stderr_file = stderr_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
103    let exit_file = exit_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
104    let pid_file = pid_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
105
106    // The agent runs in its worktree when worktree mode is active; otherwise it
107    // runs in the project root. Capture/state files and the `devflow check`
108    // calls below always use the main project root, regardless of cwd.
109    let workdir = state
110        .worktree_path
111        .as_deref()
112        .unwrap_or(&state.project_root)
113        .to_str()
114        .ok_or(MonitorError::NonUtf8Path)?;
115
116    // Shell script that launches the agent in the background, captures its
117    // stdout and exit code, then advances the workflow. Because this process
118    // is the agent's parent, capture survives the CLI exiting.
119    //
120    // stderr is captured to a separate file so it cannot corrupt the (possibly
121    // JSON) stdout capture that DevFlow parses for DEVFLOW_RESULT. Inspect
122    // .devflow/phase-NN-stderr.log for agent error output on failures.
123    //
124    // `devflow advance --phase N` evaluates the agent result, moves the stage
125    // machine forward, and (for an agent stage) spawns the next monitor
126    // itself. The phase is recorded here at spawn time so advance's identity
127    // never depends on a shared state singleton (13-DEFERRED-CR-03): under
128    // `devflow parallel`, each phase's monitor advances exactly its own
129    // stage machine.
130    //
131    // Traps SIGTERM and SIGINT for clean shutdown. WR-08 (13-REVIEW.md):
132    // the trap must also kill the backgrounded agent ($apid) — previously
133    // it only exited the monitor shell itself, orphaning the agent so it
134    // kept running/committing unsupervised with nothing left to call
135    // `devflow advance` once it finished. `apid` is initialized to empty
136    // before the trap is installed so a signal arriving before the agent is
137    // even backgrounded doesn't reference an unset variable.
138    let advance_tail = if run_advance {
139        format!(
140            "; {binary} advance {project_root} --phase {phase}",
141            binary = shell_escape(&binary),
142            project_root = shell_escape(project_root),
143            phase = state.phase,
144        )
145    } else {
146        String::new()
147    };
148    let script = format!(
149        "apid=''; cleanup() {{ [ -n \"$apid\" ] && kill \"$apid\" 2>/dev/null; exit 0; }}; \
150         trap cleanup TERM INT; \
151         cd {workdir} || exit 1; \
152         \"$@\" > {stdout_file} 2>{stderr_file} & \
153         apid=$!; echo $apid > {pid_file}; \
154         wait $apid; echo $? > {exit_file}{advance_tail}",
155        workdir = shell_escape(workdir),
156        stdout_file = shell_escape(stdout_file),
157        stderr_file = shell_escape(stderr_file),
158        exit_file = shell_escape(exit_file),
159        pid_file = shell_escape(pid_file),
160    );
161
162    let child = Command::new("sh")
163        .arg("-c")
164        .arg(&script)
165        .arg("sh")
166        .arg(program)
167        .args(args)
168        // Adapter-scoped env (e.g. Codex's unsigned-commit override) rides
169        // the whole monitor chain: sh → agent → its git children (13-06).
170        .envs(envs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
171        .stdin(Stdio::null())
172        .stdout(Stdio::null())
173        .stderr(Stdio::null())
174        .spawn()?;
175
176    let pid = child.id();
177    info!("monitor spawned with pid {pid}");
178    Ok(pid)
179}
180
181/// Poll for the agent PID that the monitor records, for up to ~1 second.
182///
183/// Returns the PID once the monitor has launched the agent, or `None` if it
184/// does not appear in time (the monitor still runs; only the display PID is lost).
185pub fn wait_for_agent_pid(project_root: &Path, phase: u32) -> Option<u32> {
186    let path = crate::agent_result::agent_pid_path(project_root, phase);
187    debug!("polling for agent PID for phase {phase}");
188    for _ in 0..50 {
189        if let Ok(contents) = std::fs::read_to_string(&path)
190            && let Ok(pid) = contents.trim().parse::<u32>()
191        {
192            return Some(pid);
193        }
194        std::thread::sleep(Duration::from_millis(20));
195    }
196    debug!("agent PID not found for phase {phase} after polling");
197    None
198}
199
200/// Block until the monitor records the agent's exit code, returning it.
201///
202/// Used by callers that need a synchronous run on top of monitor-owned
203/// execution (sequentagent's rebase handoff). Polls the exit file; if the
204/// monitor process disappears without ever writing it (killed, crashed),
205/// returns an error instead of hanging forever. There is deliberately no
206/// time-based cap — agent runs are legitimately long (tens of minutes) and
207/// monitor liveness is the meaningful bound.
208pub fn wait_for_agent_exit(
209    project_root: &Path,
210    phase: u32,
211    monitor_pid: u32,
212) -> Result<i32, MonitorError> {
213    let exit_path = crate::agent_result::exit_code_path(project_root, phase);
214    loop {
215        if let Ok(contents) = std::fs::read_to_string(&exit_path)
216            && let Ok(code) = contents.trim().parse::<i32>()
217        {
218            return Ok(code);
219        }
220        if !crate::agent::agent_running(monitor_pid) {
221            // One final read: the monitor may have written the file and
222            // exited between our read above and the liveness check.
223            if let Ok(contents) = std::fs::read_to_string(&exit_path)
224                && let Ok(code) = contents.trim().parse::<i32>()
225            {
226                return Ok(code);
227            }
228            return Err(MonitorError::Io(std::io::Error::other(format!(
229                "monitor (pid {monitor_pid}) exited without recording an exit code for phase {phase}"
230            ))));
231        }
232        std::thread::sleep(Duration::from_millis(100));
233    }
234}
235
236/// Escape a string for safe use in a single-quoted shell context.
237fn shell_escape(s: &str) -> String {
238    format!("'{}'", s.replace('\'', "'\\''"))
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::mode::Mode;
245    use crate::stage::Stage;
246    use crate::state::{AgentKind, State};
247
248    fn state_in(root: &Path) -> State {
249        let mut state = State::new(4, AgentKind::Claude, Mode::Auto, root.to_path_buf());
250        state.stage = Stage::Code;
251        state
252    }
253
254    #[test]
255    fn shell_escape_wraps_basic_strings() {
256        assert_eq!(shell_escape("hello"), "'hello'");
257        assert_eq!(shell_escape("hello world"), "'hello world'");
258        assert_eq!(shell_escape("/tmp/devflow"), "'/tmp/devflow'");
259    }
260
261    #[test]
262    fn shell_escape_handles_single_quotes() {
263        assert_eq!(shell_escape("can't"), "'can'\\''t'");
264        assert_eq!(shell_escape("a'b'c"), "'a'\\''b'\\''c'");
265    }
266
267    #[test]
268    fn shell_escape_handles_empty_string() {
269        assert_eq!(shell_escape(""), "''");
270    }
271
272    #[test]
273    fn wait_for_agent_pid_returns_pid_when_file_exists() {
274        let dir = tempfile::tempdir().unwrap();
275        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
276        std::fs::write(
277            crate::agent_result::agent_pid_path(dir.path(), 4),
278            "12345\n",
279        )
280        .unwrap();
281
282        assert_eq!(wait_for_agent_pid(dir.path(), 4), Some(12345));
283    }
284
285    #[test]
286    fn wait_for_agent_pid_returns_none_when_file_missing() {
287        let dir = tempfile::tempdir().unwrap();
288
289        assert_eq!(wait_for_agent_pid(dir.path(), 4), None);
290    }
291
292    #[test]
293    fn wait_for_agent_pid_returns_none_for_garbage_content() {
294        let dir = tempfile::tempdir().unwrap();
295        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
296        std::fs::write(
297            crate::agent_result::agent_pid_path(dir.path(), 4),
298            "not-a-pid",
299        )
300        .unwrap();
301
302        assert_eq!(wait_for_agent_pid(dir.path(), 4), None);
303    }
304
305    #[test]
306    fn spawn_monitor_captures_agent_pid_and_output() {
307        let dir = tempfile::tempdir().unwrap();
308        let state = state_in(dir.path());
309        // Stub agent: write a known marker to stdout, then exit cleanly.
310        let args = vec!["-c".to_string(), "echo MONITOR_READY".to_string()];
311
312        let monitor_pid = spawn_monitor(&state, "sh", &args, &[]).unwrap();
313        assert!(monitor_pid > 0);
314
315        // Observable side effect #1: the monitor records the agent PID to its
316        // pid file with valid numeric content.
317        let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
318            .expect("monitor should record the agent pid");
319        assert!(agent_pid > 0);
320
321        // Observable side effect #2: the agent's stdout is captured to the
322        // phase stdout file (proving the monitor actually ran the agent).
323        let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
324        let mut captured = String::new();
325        for _ in 0..100 {
326            if let Ok(contents) = std::fs::read_to_string(&stdout_path)
327                && contents.contains("MONITOR_READY")
328            {
329                captured = contents;
330                break;
331            }
332            std::thread::sleep(Duration::from_millis(20));
333        }
334        assert!(
335            captured.contains("MONITOR_READY"),
336            "expected MONITOR_READY in captured stdout, got {captured:?}"
337        );
338    }
339
340    /// WR-08 (13-REVIEW.md): sending SIGTERM/SIGINT to the monitor must also
341    /// terminate the agent it owns. Before the fix, `cleanup()` only exited
342    /// the monitor shell, leaving the agent orphaned and running/committing
343    /// unsupervised with nothing left to call `devflow advance` for it.
344    #[test]
345    fn sigterm_to_monitor_also_kills_the_agent() {
346        let dir = tempfile::tempdir().unwrap();
347        let state = state_in(dir.path());
348        // Stub agent that runs long enough to observe: sleeps well past the
349        // window this test needs to send SIGTERM and check liveness.
350        let args = vec!["-c".to_string(), "sleep 30".to_string()];
351
352        let monitor_pid = spawn_monitor(&state, "sh", &args, &[]).unwrap();
353        let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
354            .expect("monitor should record the agent pid");
355        assert!(
356            crate::agent::agent_running(agent_pid),
357            "agent should be running before SIGTERM"
358        );
359
360        // SIGTERM the monitor, as an operator (or lock.rs's stale-holder
361        // reclaim path) would to abort a run.
362        unsafe {
363            libc::kill(monitor_pid as libc::pid_t, libc::SIGTERM);
364        }
365
366        // The agent should be killed promptly by the monitor's trap —
367        // poll rather than sleep a fixed amount to keep this fast and
368        // avoid flaking under load. (Window widened to 5s: at 2s this
369        // still flaked under a fully parallel workspace test run.)
370        let mut still_running = true;
371        for _ in 0..250 {
372            if !crate::agent::agent_running(agent_pid) {
373                still_running = false;
374                break;
375            }
376            std::thread::sleep(Duration::from_millis(20));
377        }
378        assert!(
379            !still_running,
380            "agent (pid {agent_pid}) was orphaned — still running after monitor SIGTERM"
381        );
382    }
383
384    /// 14b: sequentagent's synchronous handoff = no-advance monitor + a
385    /// blocking wait on the exit file. The monitor still owns capture; the
386    /// caller gets the real exit code back.
387    #[test]
388    fn no_advance_monitor_plus_wait_returns_exit_code_and_captures() {
389        let dir = tempfile::tempdir().unwrap();
390        let state = state_in(dir.path());
391        let args = vec!["-c".to_string(), "echo SEQ_READY; exit 3".to_string()];
392
393        let monitor_pid = spawn_monitor_no_advance(&state, "sh", &args, &[]).unwrap();
394        let code = wait_for_agent_exit(dir.path(), state.phase, monitor_pid)
395            .expect("exit code must be reaped");
396
397        assert_eq!(code, 3);
398        let captured =
399            std::fs::read_to_string(crate::agent_result::stdout_path(dir.path(), state.phase))
400                .unwrap_or_default();
401        assert!(
402            captured.contains("SEQ_READY"),
403            "stdout captured: {captured:?}"
404        );
405    }
406
407    /// A dead monitor that never wrote the exit file must yield an error, not
408    /// an infinite hang.
409    #[test]
410    fn wait_for_agent_exit_errors_when_monitor_is_gone() {
411        let dir = tempfile::tempdir().unwrap();
412        // PID that is essentially certain not to be alive; no exit file.
413        let err = wait_for_agent_exit(dir.path(), 4, 0x7FFF_FFFE).unwrap_err();
414        assert!(err.to_string().contains("without recording an exit code"));
415    }
416
417    #[test]
418    fn spawn_monitor_runs_agent_in_worktree_but_captures_in_project_root() {
419        let dir = tempfile::tempdir().unwrap();
420        let worktree = dir.path().join(".worktrees/phase-04");
421        std::fs::create_dir_all(&worktree).unwrap();
422        let mut state = state_in(dir.path());
423        state.worktree_path = Some(worktree.clone());
424
425        // Stub agent: print its cwd so the test proves the monitor changed
426        // directories before launching the agent.
427        let args = vec!["-c".to_string(), "pwd; echo WORKTREE_READY".to_string()];
428
429        let monitor_pid = spawn_monitor(&state, "sh", &args, &[]).unwrap();
430        assert!(monitor_pid > 0);
431
432        let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
433            .expect("monitor should record the agent pid in the main project");
434        assert!(agent_pid > 0);
435
436        let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
437        let mut captured = String::new();
438        for _ in 0..100 {
439            if let Ok(contents) = std::fs::read_to_string(&stdout_path)
440                && contents.contains("WORKTREE_READY")
441            {
442                captured = contents;
443                break;
444            }
445            std::thread::sleep(Duration::from_millis(20));
446        }
447
448        assert!(
449            captured.contains(&worktree.display().to_string()),
450            "agent did not run in worktree cwd; captured stdout: {captured:?}"
451        );
452        assert!(
453            stdout_path.exists(),
454            "stdout capture missing in main .devflow"
455        );
456        assert!(
457            !crate::agent_result::stdout_path(&worktree, state.phase).exists(),
458            "stdout capture should not be written under the worktree"
459        );
460    }
461
462    #[test]
463    fn spawn_monitor_treats_agent_args_as_literal_argv() {
464        let dir = tempfile::tempdir().unwrap();
465        let state = state_in(dir.path());
466        let payload = "value; touch INJECTED";
467        let args = vec![
468            "-c".to_string(),
469            "printf '%s\\n' \"$0\"; echo ARGV_SAFE".to_string(),
470            payload.to_string(),
471        ];
472
473        spawn_monitor(&state, "sh", &args, &[]).unwrap();
474        wait_for_agent_pid(dir.path(), state.phase).expect("monitor should record the agent pid");
475
476        let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
477        let mut captured = String::new();
478        for _ in 0..100 {
479            if let Ok(contents) = std::fs::read_to_string(&stdout_path)
480                && contents.contains("ARGV_SAFE")
481            {
482                captured = contents;
483                break;
484            }
485            std::thread::sleep(Duration::from_millis(20));
486        }
487
488        assert!(
489            captured.contains(payload),
490            "literal argv missing: {captured:?}"
491        );
492        assert!(captured.contains("ARGV_SAFE"));
493        assert!(!dir.path().join("INJECTED").exists());
494    }
495}