use crate::state::State;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::Duration;
use tracing::{debug, info};
#[derive(Debug, thiserror::Error)]
pub enum MonitorError {
#[error("failed to spawn monitor: {0}")]
Io(#[from] std::io::Error),
#[error("project path is not valid UTF-8")]
NonUtf8Path,
#[error("could not determine devflow binary path")]
NoBinaryPath,
}
pub fn spawn_monitor(
state: &State,
program: &str,
args: &[String],
envs: &[(String, String)],
) -> Result<u32, MonitorError> {
spawn_monitor_inner(state, program, args, envs, true)
}
pub fn spawn_monitor_no_advance(
state: &State,
program: &str,
args: &[String],
envs: &[(String, String)],
) -> Result<u32, MonitorError> {
spawn_monitor_inner(state, program, args, envs, false)
}
fn spawn_monitor_inner(
state: &State,
program: &str,
args: &[String],
envs: &[(String, String)],
run_advance: bool,
) -> Result<u32, MonitorError> {
let project_root = state
.project_root
.to_str()
.ok_or(MonitorError::NonUtf8Path)?;
let binary = std::env::current_exe()
.map_err(|_| MonitorError::NoBinaryPath)?
.to_str()
.ok_or(MonitorError::NonUtf8Path)?
.to_string();
info!(
"spawning monitor for phase {}: {program} {}",
state.phase,
args.join(" ")
);
let stdout_file = crate::agent_result::stdout_path(&state.project_root, state.phase);
let stderr_file = crate::agent_result::stderr_path(&state.project_root, state.phase);
let exit_file = crate::agent_result::exit_code_path(&state.project_root, state.phase);
let pid_file = crate::agent_result::agent_pid_path(&state.project_root, state.phase);
if let Some(parent) = stdout_file.parent() {
std::fs::create_dir_all(parent)?;
}
let stdout_file = stdout_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
let stderr_file = stderr_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
let exit_file = exit_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
let pid_file = pid_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
let workdir = state
.worktree_path
.as_deref()
.unwrap_or(&state.project_root)
.to_str()
.ok_or(MonitorError::NonUtf8Path)?;
let advance_tail = if run_advance {
format!(
"; {binary} advance {project_root} --phase {phase}",
binary = shell_escape(&binary),
project_root = shell_escape(project_root),
phase = state.phase,
)
} else {
String::new()
};
let script = format!(
"apid=''; cleanup() {{ [ -n \"$apid\" ] && kill \"$apid\" 2>/dev/null; exit 0; }}; \
trap cleanup TERM INT; \
cd {workdir} || exit 1; \
\"$@\" > {stdout_file} 2>{stderr_file} & \
apid=$!; echo $apid > {pid_file}; \
wait $apid; echo $? > {exit_file}{advance_tail}",
workdir = shell_escape(workdir),
stdout_file = shell_escape(stdout_file),
stderr_file = shell_escape(stderr_file),
exit_file = shell_escape(exit_file),
pid_file = shell_escape(pid_file),
);
let child = Command::new("sh")
.arg("-c")
.arg(&script)
.arg("sh")
.arg(program)
.args(args)
.envs(envs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
let pid = child.id();
info!("monitor spawned with pid {pid}");
Ok(pid)
}
pub fn wait_for_agent_pid(project_root: &Path, phase: u32) -> Option<u32> {
let path = crate::agent_result::agent_pid_path(project_root, phase);
debug!("polling for agent PID for phase {phase}");
for _ in 0..50 {
if let Ok(contents) = std::fs::read_to_string(&path)
&& let Ok(pid) = contents.trim().parse::<u32>()
{
return Some(pid);
}
std::thread::sleep(Duration::from_millis(20));
}
debug!("agent PID not found for phase {phase} after polling");
None
}
pub fn wait_for_agent_exit(
project_root: &Path,
phase: u32,
monitor_pid: u32,
) -> Result<i32, MonitorError> {
let exit_path = crate::agent_result::exit_code_path(project_root, phase);
loop {
if let Ok(contents) = std::fs::read_to_string(&exit_path)
&& let Ok(code) = contents.trim().parse::<i32>()
{
return Ok(code);
}
if !crate::agent::agent_running(monitor_pid) {
if let Ok(contents) = std::fs::read_to_string(&exit_path)
&& let Ok(code) = contents.trim().parse::<i32>()
{
return Ok(code);
}
return Err(MonitorError::Io(std::io::Error::other(format!(
"monitor (pid {monitor_pid}) exited without recording an exit code for phase {phase}"
))));
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn shell_escape(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mode::Mode;
use crate::stage::Stage;
use crate::state::{AgentKind, State};
fn state_in(root: &Path) -> State {
let mut state = State::new(4, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
state
}
#[test]
fn shell_escape_wraps_basic_strings() {
assert_eq!(shell_escape("hello"), "'hello'");
assert_eq!(shell_escape("hello world"), "'hello world'");
assert_eq!(shell_escape("/tmp/devflow"), "'/tmp/devflow'");
}
#[test]
fn shell_escape_handles_single_quotes() {
assert_eq!(shell_escape("can't"), "'can'\\''t'");
assert_eq!(shell_escape("a'b'c"), "'a'\\''b'\\''c'");
}
#[test]
fn shell_escape_handles_empty_string() {
assert_eq!(shell_escape(""), "''");
}
#[test]
fn wait_for_agent_pid_returns_pid_when_file_exists() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
std::fs::write(
crate::agent_result::agent_pid_path(dir.path(), 4),
"12345\n",
)
.unwrap();
assert_eq!(wait_for_agent_pid(dir.path(), 4), Some(12345));
}
#[test]
fn wait_for_agent_pid_returns_none_when_file_missing() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(wait_for_agent_pid(dir.path(), 4), None);
}
#[test]
fn wait_for_agent_pid_returns_none_for_garbage_content() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
std::fs::write(
crate::agent_result::agent_pid_path(dir.path(), 4),
"not-a-pid",
)
.unwrap();
assert_eq!(wait_for_agent_pid(dir.path(), 4), None);
}
#[test]
fn spawn_monitor_captures_agent_pid_and_output() {
let dir = tempfile::tempdir().unwrap();
let state = state_in(dir.path());
let args = vec!["-c".to_string(), "echo MONITOR_READY".to_string()];
let monitor_pid = spawn_monitor(&state, "sh", &args, &[]).unwrap();
assert!(monitor_pid > 0);
let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
.expect("monitor should record the agent pid");
assert!(agent_pid > 0);
let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
let mut captured = String::new();
for _ in 0..100 {
if let Ok(contents) = std::fs::read_to_string(&stdout_path)
&& contents.contains("MONITOR_READY")
{
captured = contents;
break;
}
std::thread::sleep(Duration::from_millis(20));
}
assert!(
captured.contains("MONITOR_READY"),
"expected MONITOR_READY in captured stdout, got {captured:?}"
);
}
#[test]
fn sigterm_to_monitor_also_kills_the_agent() {
let dir = tempfile::tempdir().unwrap();
let state = state_in(dir.path());
let args = vec!["-c".to_string(), "sleep 30".to_string()];
let monitor_pid = spawn_monitor(&state, "sh", &args, &[]).unwrap();
let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
.expect("monitor should record the agent pid");
assert!(
crate::agent::agent_running(agent_pid),
"agent should be running before SIGTERM"
);
unsafe {
libc::kill(monitor_pid as libc::pid_t, libc::SIGTERM);
}
let mut still_running = true;
for _ in 0..250 {
if !crate::agent::agent_running(agent_pid) {
still_running = false;
break;
}
std::thread::sleep(Duration::from_millis(20));
}
assert!(
!still_running,
"agent (pid {agent_pid}) was orphaned — still running after monitor SIGTERM"
);
}
#[test]
fn no_advance_monitor_plus_wait_returns_exit_code_and_captures() {
let dir = tempfile::tempdir().unwrap();
let state = state_in(dir.path());
let args = vec!["-c".to_string(), "echo SEQ_READY; exit 3".to_string()];
let monitor_pid = spawn_monitor_no_advance(&state, "sh", &args, &[]).unwrap();
let code = wait_for_agent_exit(dir.path(), state.phase, monitor_pid)
.expect("exit code must be reaped");
assert_eq!(code, 3);
let captured =
std::fs::read_to_string(crate::agent_result::stdout_path(dir.path(), state.phase))
.unwrap_or_default();
assert!(
captured.contains("SEQ_READY"),
"stdout captured: {captured:?}"
);
}
#[test]
fn wait_for_agent_exit_errors_when_monitor_is_gone() {
let dir = tempfile::tempdir().unwrap();
let err = wait_for_agent_exit(dir.path(), 4, 0x7FFF_FFFE).unwrap_err();
assert!(err.to_string().contains("without recording an exit code"));
}
#[test]
fn spawn_monitor_runs_agent_in_worktree_but_captures_in_project_root() {
let dir = tempfile::tempdir().unwrap();
let worktree = dir.path().join(".worktrees/phase-04");
std::fs::create_dir_all(&worktree).unwrap();
let mut state = state_in(dir.path());
state.worktree_path = Some(worktree.clone());
let args = vec!["-c".to_string(), "pwd; echo WORKTREE_READY".to_string()];
let monitor_pid = spawn_monitor(&state, "sh", &args, &[]).unwrap();
assert!(monitor_pid > 0);
let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
.expect("monitor should record the agent pid in the main project");
assert!(agent_pid > 0);
let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
let mut captured = String::new();
for _ in 0..100 {
if let Ok(contents) = std::fs::read_to_string(&stdout_path)
&& contents.contains("WORKTREE_READY")
{
captured = contents;
break;
}
std::thread::sleep(Duration::from_millis(20));
}
assert!(
captured.contains(&worktree.display().to_string()),
"agent did not run in worktree cwd; captured stdout: {captured:?}"
);
assert!(
stdout_path.exists(),
"stdout capture missing in main .devflow"
);
assert!(
!crate::agent_result::stdout_path(&worktree, state.phase).exists(),
"stdout capture should not be written under the worktree"
);
}
#[test]
fn spawn_monitor_treats_agent_args_as_literal_argv() {
let dir = tempfile::tempdir().unwrap();
let state = state_in(dir.path());
let payload = "value; touch INJECTED";
let args = vec![
"-c".to_string(),
"printf '%s\\n' \"$0\"; echo ARGV_SAFE".to_string(),
payload.to_string(),
];
spawn_monitor(&state, "sh", &args, &[]).unwrap();
wait_for_agent_pid(dir.path(), state.phase).expect("monitor should record the agent pid");
let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
let mut captured = String::new();
for _ in 0..100 {
if let Ok(contents) = std::fs::read_to_string(&stdout_path)
&& contents.contains("ARGV_SAFE")
{
captured = contents;
break;
}
std::thread::sleep(Duration::from_millis(20));
}
assert!(
captured.contains(payload),
"literal argv missing: {captured:?}"
);
assert!(captured.contains("ARGV_SAFE"));
assert!(!dir.path().join("INJECTED").exists());
}
}