1use crate::git::hermetic_command;
15use crate::state::State;
16use std::path::Path;
17use std::process::Stdio;
18use std::time::Duration;
19use tracing::{debug, info};
20
21#[derive(Debug, thiserror::Error)]
23pub enum MonitorError {
24 #[error("failed to spawn monitor: {0}")]
26 Io(#[from] std::io::Error),
27 #[error("project path is not valid UTF-8")]
29 NonUtf8Path,
30 #[error("could not determine devflow binary path")]
32 NoBinaryPath,
33}
34
35pub fn spawn_monitor(
46 state: &State,
47 program: &str,
48 args: &[String],
49 envs: &[(String, String)],
50) -> Result<u32, MonitorError> {
51 spawn_monitor_inner(state, program, args, envs, true)
52}
53
54fn spawn_monitor_inner(
55 state: &State,
56 program: &str,
57 args: &[String],
58 envs: &[(String, String)],
59 run_advance: bool,
60) -> Result<u32, MonitorError> {
61 let project_root = state
62 .project_root
63 .to_str()
64 .ok_or(MonitorError::NonUtf8Path)?;
65
66 let binary = std::env::current_exe()
67 .map_err(|_| MonitorError::NoBinaryPath)?
68 .to_str()
69 .ok_or(MonitorError::NonUtf8Path)?
70 .to_string();
71
72 info!(
73 "spawning monitor for phase {}: {program} {}",
74 state.phase,
75 args.join(" ")
76 );
77
78 let stdout_file = crate::agent_result::stdout_path(&state.project_root, state.phase);
79 let stderr_file = crate::agent_result::stderr_path(&state.project_root, state.phase);
80 let exit_file = crate::agent_result::exit_code_path(&state.project_root, state.phase);
81 let pid_file = crate::agent_result::agent_pid_path(&state.project_root, state.phase);
82
83 if let Some(parent) = stdout_file.parent() {
85 crate::workflow::ensure_devflow_dir(parent)?;
86 }
87
88 let stdout_file = stdout_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
89 let stderr_file = stderr_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
90 let exit_file = exit_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
91 let pid_file = pid_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
92
93 let workdir_path = state
97 .worktree_path
98 .as_deref()
99 .unwrap_or(&state.project_root);
100 let workdir = workdir_path.to_str().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 = hermetic_command("sh", workdir_path)
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
200fn shell_escape(s: &str) -> String {
202 format!("'{}'", s.replace('\'', "'\\''"))
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::mode::Mode;
209 use crate::stage::Stage;
210 use crate::state::{AgentKind, State};
211
212 fn state_in(root: &Path) -> State {
213 let mut state = State::new(4, AgentKind::Claude, Mode::Auto, root.to_path_buf());
214 state.stage = Stage::Code;
215 state
216 }
217
218 #[test]
219 fn shell_escape_wraps_basic_strings() {
220 assert_eq!(shell_escape("hello"), "'hello'");
221 assert_eq!(shell_escape("hello world"), "'hello world'");
222 assert_eq!(shell_escape("/tmp/devflow"), "'/tmp/devflow'");
223 }
224
225 #[test]
226 fn shell_escape_handles_single_quotes() {
227 assert_eq!(shell_escape("can't"), "'can'\\''t'");
228 assert_eq!(shell_escape("a'b'c"), "'a'\\''b'\\''c'");
229 }
230
231 #[test]
232 fn shell_escape_handles_empty_string() {
233 assert_eq!(shell_escape(""), "''");
234 }
235
236 #[test]
237 fn wait_for_agent_pid_returns_pid_when_file_exists() {
238 let dir = tempfile::tempdir().unwrap();
239 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
240 std::fs::write(
241 crate::agent_result::agent_pid_path(dir.path(), 4),
242 "12345\n",
243 )
244 .unwrap();
245
246 assert_eq!(wait_for_agent_pid(dir.path(), 4), Some(12345));
247 }
248
249 #[test]
250 fn wait_for_agent_pid_returns_none_when_file_missing() {
251 let dir = tempfile::tempdir().unwrap();
252
253 assert_eq!(wait_for_agent_pid(dir.path(), 4), None);
254 }
255
256 #[test]
257 fn wait_for_agent_pid_returns_none_for_garbage_content() {
258 let dir = tempfile::tempdir().unwrap();
259 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
260 std::fs::write(
261 crate::agent_result::agent_pid_path(dir.path(), 4),
262 "not-a-pid",
263 )
264 .unwrap();
265
266 assert_eq!(wait_for_agent_pid(dir.path(), 4), None);
267 }
268
269 #[test]
270 fn spawn_monitor_captures_agent_pid_and_output() {
271 let dir = tempfile::tempdir().unwrap();
272 let state = state_in(dir.path());
273 let args = vec!["-c".to_string(), "echo MONITOR_READY".to_string()];
275
276 let monitor_pid = spawn_monitor(&state, "sh", &args, &[]).unwrap();
277 assert!(monitor_pid > 0);
278
279 let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
282 .expect("monitor should record the agent pid");
283 assert!(agent_pid > 0);
284
285 let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
288 let mut captured = String::new();
289 for _ in 0..100 {
290 if let Ok(contents) = std::fs::read_to_string(&stdout_path)
291 && contents.contains("MONITOR_READY")
292 {
293 captured = contents;
294 break;
295 }
296 std::thread::sleep(Duration::from_millis(20));
297 }
298 assert!(
299 captured.contains("MONITOR_READY"),
300 "expected MONITOR_READY in captured stdout, got {captured:?}"
301 );
302 }
303
304 fn proc_snapshot(pid: u32) -> String {
313 let Ok(status) = std::fs::read_to_string(format!("/proc/{pid}/status")) else {
314 return format!("GONE (no /proc/{pid})");
315 };
316 let field = |key: &str| {
317 status
318 .lines()
319 .find(|l| l.starts_with(key))
320 .map(|l| l.split_whitespace().skip(1).collect::<Vec<_>>().join(" "))
321 .unwrap_or_else(|| "?".into())
322 };
323 let cmdline = std::fs::read(format!("/proc/{pid}/cmdline"))
324 .map(|raw| {
325 let joined = raw
326 .split(|&b| b == 0)
327 .filter(|a| !a.is_empty())
328 .map(|a| String::from_utf8_lossy(a).into_owned())
329 .collect::<Vec<_>>()
330 .join(" ");
331 if joined.is_empty() {
332 "<empty>".to_string()
333 } else {
334 joined
335 }
336 })
337 .unwrap_or_else(|e| format!("<unreadable: {e}>"));
338 format!(
339 "ALIVE Name={} State={} PPid={} cmdline=[{cmdline}]",
340 field("Name:"),
341 field("State:"),
342 field("PPid:")
343 )
344 }
345
346 #[test]
347 fn sigterm_to_monitor_also_kills_the_agent() {
348 let dir = tempfile::tempdir().unwrap();
349 let state = state_in(dir.path());
350 let args = vec!["-c".to_string(), "sleep 30".to_string()];
353
354 let monitor_pid = spawn_monitor(&state, "sh", &args, &[]).unwrap();
355 let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
356 .expect("monitor should record the agent pid");
357 assert!(
358 crate::agent::agent_running(agent_pid),
359 "agent should be running before SIGTERM"
360 );
361
362 let monitor_before = proc_snapshot(monitor_pid);
367 let agent_before = proc_snapshot(agent_pid);
368
369 let kill_rc = unsafe { libc::kill(monitor_pid as libc::pid_t, libc::SIGTERM) };
372 let kill_err = if kill_rc == 0 {
373 "ok".to_string()
374 } else {
375 format!("errno {}", std::io::Error::last_os_error())
376 };
377
378 let mut still_running = true;
401 for _ in 0..250 {
402 if !crate::agent::agent_running(agent_pid) {
403 still_running = false;
404 break;
405 }
406 std::thread::sleep(Duration::from_millis(20));
407 }
408 let monitor_after = proc_snapshot(monitor_pid);
409 let agent_after = proc_snapshot(agent_pid);
410 let pidfile =
411 std::fs::read_to_string(crate::agent_result::agent_pid_path(dir.path(), state.phase))
412 .unwrap_or_else(|e| format!("<unreadable: {e}>"));
413
414 assert!(
415 !still_running,
416 "agent (pid {agent_pid}) was orphaned — still running after monitor SIGTERM\n\
417 \x20 monitor pid: {monitor_pid}\n\
418 \x20 kill(TERM) rc: {kill_rc} ({kill_err})\n\
419 \x20 monitor before: {monitor_before}\n\
420 \x20 monitor after: {monitor_after}\n\
421 \x20 agent pid: {agent_pid}\n\
422 \x20 agent before: {agent_before}\n\
423 \x20 agent after: {agent_after}\n\
424 \x20 pidfile contents: {}\n\
425 Read the monitor's `after` line first. GONE means the shell died \
426 without running its trap — most likely SIGTERM arrived before \
427 `trap` was installed, or it was killed rather than handling the \
428 signal, either way leaving the agent unreaped. STILL ALIVE means \
429 the trap never fired or `kill $apid` failed, so compare the agent \
430 pid against the pidfile and check the agent's PPid: if PPid is not \
431 the monitor, `$!` did not name the process we are polling. If the \
432 agent's Name is `sh` rather than `sleep`, the agent shell forked \
433 rather than exec'd, so killing it leaves its own child behind.",
434 pidfile.trim()
435 );
436 }
437
438 #[test]
439 fn spawn_monitor_runs_agent_in_worktree_but_captures_in_project_root() {
440 let dir = tempfile::tempdir().unwrap();
441 let worktree = dir.path().join(".worktrees/phase-04");
442 std::fs::create_dir_all(&worktree).unwrap();
443 let mut state = state_in(dir.path());
444 state.worktree_path = Some(worktree.clone());
445
446 let args = vec!["-c".to_string(), "pwd; echo WORKTREE_READY".to_string()];
449
450 let monitor_pid = spawn_monitor(&state, "sh", &args, &[]).unwrap();
451 assert!(monitor_pid > 0);
452
453 let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
454 .expect("monitor should record the agent pid in the main project");
455 assert!(agent_pid > 0);
456
457 let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
458 let mut captured = String::new();
459 for _ in 0..100 {
460 if let Ok(contents) = std::fs::read_to_string(&stdout_path)
461 && contents.contains("WORKTREE_READY")
462 {
463 captured = contents;
464 break;
465 }
466 std::thread::sleep(Duration::from_millis(20));
467 }
468
469 assert!(
470 captured.contains(&worktree.display().to_string()),
471 "agent did not run in worktree cwd; captured stdout: {captured:?}"
472 );
473 assert!(
474 stdout_path.exists(),
475 "stdout capture missing in main .devflow"
476 );
477 assert!(
478 !crate::agent_result::stdout_path(&worktree, state.phase).exists(),
479 "stdout capture should not be written under the worktree"
480 );
481 }
482
483 fn git(root: &Path, args: &[&str]) {
493 let ok = crate::test_support::git_command(root)
494 .args(args)
495 .output()
496 .unwrap()
497 .status
498 .success();
499 assert!(ok, "git {args:?} failed");
500 }
501
502 fn init_repo(root: &Path) {
503 git(root, &["init", "-q"]);
504 git(root, &["config", "user.email", "test@example.com"]);
505 git(root, &["config", "user.name", "Test"]);
506 }
507
508 #[test]
523 fn spawn_monitor_agent_git_calls_resolve_workdir_not_a_hostile_git_dir() {
524 const INNER_ROOT: &str = "DEVFLOW_27_MONITOR_INNER_ROOT";
525
526 if let Ok(root) = std::env::var(INNER_ROOT) {
527 let root = std::path::PathBuf::from(root);
530 let state = state_in(&root);
531 let args = vec![
532 "-c".to_string(),
533 "git rev-parse --absolute-git-dir".to_string(),
534 ];
535
536 spawn_monitor(&state, "sh", &args, &[]).unwrap();
537 wait_for_agent_pid(&root, state.phase).expect("monitor should record the agent pid");
538
539 let stdout_path = crate::agent_result::stdout_path(&root, state.phase);
540 let mut captured = String::new();
541 for _ in 0..100 {
542 if let Ok(contents) = std::fs::read_to_string(&stdout_path)
543 && !contents.trim().is_empty()
544 {
545 captured = contents;
546 break;
547 }
548 std::thread::sleep(Duration::from_millis(20));
549 }
550
551 let resolved = std::fs::canonicalize(captured.trim())
552 .expect("agent's reported git-dir must exist on disk");
553 let expected =
554 std::fs::canonicalize(root.join(".git")).expect("caller repo .git must exist");
555 assert_eq!(
556 resolved, expected,
557 "agent's git call resolved to a hostile GIT_DIR's \
558 repository instead of the caller's own workdir: \
559 got {resolved:?}, want {expected:?}"
560 );
561 return;
562 }
563
564 let dir = tempfile::tempdir().unwrap();
568 let root = dir.path().join("caller-repo");
569 std::fs::create_dir_all(&root).unwrap();
570 init_repo(&root);
571
572 let foreign = tempfile::tempdir().unwrap();
573 init_repo(foreign.path());
574
575 let exe = std::env::current_exe().expect("current_exe for child re-invocation");
576 let out = std::process::Command::new(&exe)
577 .arg("spawn_monitor_agent_git_calls_resolve_workdir_not_a_hostile_git_dir")
582 .arg("--test-threads=1")
583 .env(INNER_ROOT, root.to_str().unwrap())
584 .env("GIT_DIR", foreign.path().join(".git"))
585 .output()
586 .expect("spawn hostile child test process");
587
588 let stdout = String::from_utf8_lossy(&out.stdout);
589 assert!(
592 stdout.contains("1 passed"),
593 "child test process must have run exactly the inner test; \
594 stdout:\n{stdout}"
595 );
596 assert!(
597 out.status.success(),
598 "monitor-spawned agent (hostile GIT_DIR pointed at an \
599 unrelated foreign repository) must still resolve its git \
600 calls against the caller's own workdir; child exit status \
601 {:?}\nstdout:\n{stdout}",
602 out.status
603 );
604 }
605
606 #[test]
607 fn spawn_monitor_treats_agent_args_as_literal_argv() {
608 let dir = tempfile::tempdir().unwrap();
609 let state = state_in(dir.path());
610 let payload = "value; touch INJECTED";
611 let args = vec![
612 "-c".to_string(),
613 "printf '%s\\n' \"$0\"; echo ARGV_SAFE".to_string(),
614 payload.to_string(),
615 ];
616
617 spawn_monitor(&state, "sh", &args, &[]).unwrap();
618 wait_for_agent_pid(dir.path(), state.phase).expect("monitor should record the agent pid");
619
620 let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
621 let mut captured = String::new();
622 for _ in 0..100 {
623 if let Ok(contents) = std::fs::read_to_string(&stdout_path)
624 && contents.contains("ARGV_SAFE")
625 {
626 captured = contents;
627 break;
628 }
629 std::thread::sleep(Duration::from_millis(20));
630 }
631
632 assert!(
633 captured.contains(payload),
634 "literal argv missing: {captured:?}"
635 );
636 assert!(captured.contains("ARGV_SAFE"));
637 assert!(!dir.path().join("INJECTED").exists());
638 }
639}