ralph-workflow 0.7.18

PROMPT-driven multi-agent orchestrator for git repos
Documentation
use super::super::io::{force_kill_best_effort, kill_process, KillResult};
use super::super::runtime::sleep::sleep_until_next_check_or_stop;
use super::super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

#[test]
fn monitor_result_variants_are_distinct() {
    assert_ne!(
        MonitorResult::ProcessCompleted,
        MonitorResult::TimedOut {
            escalated: false,
            child_status_at_timeout: None
        }
    );
    assert_ne!(
        MonitorResult::ProcessCompleted,
        MonitorResult::TimedOut {
            escalated: true,
            child_status_at_timeout: None
        }
    );
    assert_ne!(
        MonitorResult::TimedOut {
            escalated: false,
            child_status_at_timeout: None
        },
        MonitorResult::TimedOut {
            escalated: true,
            child_status_at_timeout: None
        }
    );
}

#[test]
fn monitor_stops_when_signaled() {
    use crate::executor::MockAgentChild;

    let timestamp = new_activity_timestamp();
    let should_stop = Arc::new(AtomicBool::new(false));
    let should_stop_clone = Arc::clone(&should_stop);

    let mock_child = MockAgentChild::new(0);
    let child = Arc::new(Mutex::new(
        Box::new(mock_child) as Box<dyn crate::executor::AgentChild>
    ));

    let executor: Arc<dyn crate::executor::ProcessExecutor> =
        Arc::new(crate::executor::MockProcessExecutor::new());

    let check_interval = Duration::from_millis(1);
    let handle = thread::spawn(move || {
        monitor_idle_timeout_with_interval(
            &timestamp,
            &child,
            Duration::from_secs(60),
            &should_stop_clone,
            &executor,
            check_interval,
        )
    });

    thread::sleep(Duration::from_millis(5));
    should_stop.store(true, Ordering::Release);

    let result = handle.join().expect("Monitor thread panicked");
    assert_eq!(result, MonitorResult::ProcessCompleted);
}

#[test]
fn monitor_stops_promptly_even_with_long_check_interval() {
    use crate::executor::MockAgentChild;

    let timestamp = new_activity_timestamp();
    let should_stop = Arc::new(AtomicBool::new(false));
    let should_stop_clone = Arc::clone(&should_stop);

    let (mock_child, controller) = MockAgentChild::new_running(0);
    let child = Arc::new(Mutex::new(
        Box::new(mock_child) as Box<dyn crate::executor::AgentChild>
    ));

    let executor: Arc<dyn crate::executor::ProcessExecutor> =
        Arc::new(crate::executor::MockProcessExecutor::new());

    let check_interval = Duration::from_secs(1);
    let start = std::time::Instant::now();
    let handle = thread::spawn(move || {
        monitor_idle_timeout_with_interval(
            &timestamp,
            &child,
            Duration::from_secs(60),
            &should_stop_clone,
            &executor,
            check_interval,
        )
    });

    thread::sleep(Duration::from_millis(20));
    should_stop.store(true, Ordering::Release);
    controller.store(false, Ordering::Release);

    let result = handle.join().expect("Monitor thread panicked");
    assert_eq!(result, MonitorResult::ProcessCompleted);
    assert!(
        start.elapsed() < Duration::from_millis(300),
        "monitor should stop promptly after stop signal"
    );
}

#[test]
#[cfg(unix)]
fn kill_process_returns_failed_when_sigterm_command_exits_nonzero() {
    use std::io;
    use std::path::Path;
    use std::process::ExitStatus;

    #[cfg(unix)]
    use std::os::unix::process::ExitStatusExt;

    #[derive(Debug)]
    struct NonZeroKillExecutor;

    impl crate::executor::ProcessExecutor for NonZeroKillExecutor {
        fn execute(
            &self,
            _command: &str,
            _args: &[&str],
            _env: &[(String, String)],
            _workdir: Option<&Path>,
        ) -> io::Result<crate::executor::ProcessOutput> {
            Ok(crate::executor::ProcessOutput {
                status: ExitStatus::from_raw(1),
                stdout: String::new(),
                stderr: String::new(),
            })
        }
    }

    let executor = NonZeroKillExecutor;
    let result = kill_process(12345, &executor, None, DEFAULT_KILL_CONFIG);
    assert_eq!(result, KillResult::Failed);
}

#[test]
#[cfg(unix)]
fn kill_command_uses_correct_syntax_for_negative_pgid() {
    let executor = crate::executor::MockProcessExecutor::new();

    let _ = kill_process(12345, &executor, None, DEFAULT_KILL_CONFIG);

    let calls = executor.execute_calls_for("kill");
    assert!(!calls.is_empty(), "expected at least one kill invocation");
    assert_eq!(calls[0].1, vec!["-TERM", "--", "-12345"]);
}

#[test]
#[cfg(unix)]
fn force_kill_best_effort_uses_correct_syntax_for_negative_pgid() {
    let executor = crate::executor::MockProcessExecutor::new();

    let ok = force_kill_best_effort(12345, &executor);
    assert!(ok);

    let calls = executor.execute_calls_for("kill");
    assert!(!calls.is_empty(), "expected at least one kill invocation");
    assert_eq!(calls[0].1, vec!["-KILL", "--", "-12345"]);
}

/// Regression test: sleep_until_next_check_or_stop must actually sleep
/// for approximately the check interval, not busy-wait (return immediately).
/// A busy-wait regression would cause this test to complete in microseconds
/// instead of the expected ~200ms.
#[test]
fn sleep_until_next_check_actually_sleeps() {
    let should_stop = AtomicBool::new(false);
    let check_interval = Duration::from_millis(200);
    let start = std::time::Instant::now();

    let stopped = sleep_until_next_check_or_stop(&should_stop, check_interval);

    assert!(!stopped, "should_stop was not set, so must return false");
    assert!(
        start.elapsed() >= Duration::from_millis(150),
        "sleep_until_next_check_or_stop returned in {:?}, expected ~200ms — \
         this indicates a busy-wait regression (thread::sleep is missing)",
        start.elapsed()
    );
}