1use crate::state::State;
15use std::path::Path;
16use std::process::{Command, Stdio};
17use std::time::Duration;
18use tracing::{debug, info};
19
20#[derive(Debug, thiserror::Error)]
22pub enum MonitorError {
23 #[error("failed to spawn monitor: {0}")]
25 Io(#[from] std::io::Error),
26 #[error("project path is not valid UTF-8")]
28 NonUtf8Path,
29 #[error("could not determine devflow binary path")]
31 NoBinaryPath,
32}
33
34pub 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 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 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 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 .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
167pub 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
186fn 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 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 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 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 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 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 let monitor_before = proc_snapshot(monitor_pid);
353 let agent_before = proc_snapshot(agent_pid);
354
355 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 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 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}