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
53pub 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 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 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 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 .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
181pub 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
200pub 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 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
236fn 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 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 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 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 #[test]
345 fn sigterm_to_monitor_also_kills_the_agent() {
346 let dir = tempfile::tempdir().unwrap();
347 let state = state_in(dir.path());
348 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 unsafe {
363 libc::kill(monitor_pid as libc::pid_t, libc::SIGTERM);
364 }
365
366 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 #[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 #[test]
410 fn wait_for_agent_exit_errors_when_monitor_is_gone() {
411 let dir = tempfile::tempdir().unwrap();
412 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 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}