malvin 0.2.5

Non-interactive research and coding agent
mod common;

#[cfg(unix)]
use common::{fresh_workdir, sleep_child, write_peer_acp_lock};
#[cfg(unix)]
use malvin::acp::snapshot_pids;
#[cfg(unix)]
use malvin::malvin_sandbox::{
    assert_dead_before_next_spawn, assert_no_peer_acp_spawn_lock, clear_active_sandbox_session,
    malvin_std_command, malvin_tokio_command, note_active_sandbox_session,
};
#[cfg(unix)]
use std::process::Command;

#[cfg(unix)]
fn process_group_id(pid: u32) -> u32 {
    let out = Command::new("ps")
        .args(["-p", &pid.to_string(), "-o", "pgid="])
        .output()
        .expect("ps pgid");
    let text = String::from_utf8_lossy(&out.stdout);
    text.trim().parse().expect("pgid parse")
}

#[cfg(unix)]
#[test]
fn dead_before_next_rejects_live_prior_sandbox() {
    clear_active_sandbox_session();
    let baseline = snapshot_pids();
    let mut child = sleep_child("120");
    let pgid = child.id();
    let work = fresh_workdir("malvin_dead_before_next_reject");
    note_active_sandbox_session(
        malvin::malvin_sandbox::take_sandbox_spawn_ticket().expect("ticket"),
        Some(pgid),
        baseline,
        &work,
    )
    .expect("note");
    let err = assert_dead_before_next_spawn().expect_err("live prior sandbox");
    assert!(
        err.contains("still alive"),
        "expected dead-before-next error, got: {err}"
    );
    let _ = child.kill();
    let _ = child.wait();
    clear_active_sandbox_session();
}

#[cfg(unix)]
#[test]
fn dead_before_next_allows_after_prior_sandbox_cleared() {
    clear_active_sandbox_session();
    assert_dead_before_next_spawn().expect("no prior sandbox");
    let baseline = snapshot_pids();
    let mut child = sleep_child("1");
    let pgid = child.id();
    let work = fresh_workdir("malvin_dead_before_next_clear");
    note_active_sandbox_session(
        malvin::malvin_sandbox::take_sandbox_spawn_ticket().expect("ticket"),
        Some(pgid),
        baseline,
        &work,
    )
    .expect("note");
    let _ = child.kill();
    let _ = child.wait();
    clear_active_sandbox_session();
    assert_dead_before_next_spawn().expect("prior sandbox ended");
}

#[cfg(unix)]
#[test]
fn concurrent_sessions_allowed_with_live_peer_in_workspace() {
    clear_active_sandbox_session();
    let work = fresh_workdir("malvin_peer_acp_spawn_lock");
    std::fs::create_dir_all(work.join(".malvin/acp_spawn")).expect("mkdir .malvin");
    let mut child = sleep_child("120");
    let holder_pid = child.id();
    let slot = "sharedslot";
    let lock = work
        .join(".malvin")
        .join("acp_spawn")
        .join(format!("{slot}.lock"));
    std::fs::write(&lock, holder_pid.to_string()).expect("write lock");
    let err = malvin::assert_no_peer_acp_spawn_lock_for_slot(&work, slot)
        .expect_err("peer lock must block");
    assert!(
        err.contains("ACP spawn lock held"),
        "expected peer lock error, got: {err}"
    );
    let _ = child.kill();
    let _ = child.wait();
    malvin::assert_no_peer_acp_spawn_lock_for_slot(&work, slot)
        .expect("stale lock cleared after holder exit");
    assert!(!lock.exists(), "stale lock file removed");
}

#[cfg(unix)]
#[test]
fn acp_spawn_lock_acquired_and_released_by_session_lifecycle() {
    clear_active_sandbox_session();
    let work = fresh_workdir("malvin_acp_lock_lifecycle");
    let baseline = snapshot_pids();
    malvin::set_active_acp_lock_slot("lifecycle".to_string());
    let lock = work
        .join(".malvin")
        .join("acp_spawn")
        .join("lifecycle.lock");
    note_active_sandbox_session(
        malvin::malvin_sandbox::take_sandbox_spawn_ticket().expect("ticket"),
        None,
        baseline,
        &work,
    )
    .expect("acquire lock");
    assert!(lock.is_file(), "lock file should exist after note_active");
    assert_eq!(
        std::fs::read_to_string(&lock).expect("read lock").trim(),
        std::process::id().to_string()
    );
    clear_active_sandbox_session();
    assert!(!lock.exists(), "lock file should be removed after clear");
}

#[cfg(unix)]
#[test]
fn peer_acp_spawn_lock_allows_same_process_holder() {
    clear_active_sandbox_session();
    let work = fresh_workdir("malvin_peer_acp_spawn_lock_self");
    std::fs::create_dir_all(work.join(".malvin/acp_spawn")).expect("mkdir .malvin");
    malvin::set_active_acp_lock_slot("selfslot".to_string());
    let lock = work.join(".malvin").join("acp_spawn").join("selfslot.lock");
    std::fs::write(&lock, std::process::id().to_string()).expect("write self lock");
    assert_no_peer_acp_spawn_lock(&work).expect("same-process holder allowed");
    assert!(lock.exists(), "self-held lock must remain");
}

#[cfg(unix)]
#[test]
fn note_active_sandbox_session_rejects_live_peer_lock() {
    clear_active_sandbox_session();
    let work = fresh_workdir("malvin_note_active_peer_lock");
    let mut child = sleep_child("120");
    malvin::set_active_acp_lock_slot("peerslot".to_string());
    write_peer_acp_lock(&work, "peerslot", child.id());
    let baseline = snapshot_pids();
    let err = note_active_sandbox_session(
        malvin::malvin_sandbox::take_sandbox_spawn_ticket().expect("ticket"),
        None,
        baseline,
        &work,
    )
    .expect_err("peer blocks note");
    assert!(
        err.contains("ACP spawn lock held"),
        "expected peer lock error, got: {err}"
    );
    let _ = child.kill();
    let _ = child.wait();
}

#[cfg(unix)]
#[test]
fn session_lifecycle_does_not_touch_acp_spawn_lock() {
    clear_active_sandbox_session();
    let work = fresh_workdir("malvin_no_lock_lifecycle");
    let baseline = snapshot_pids();
    malvin::set_active_acp_lock_slot("foreignslot".to_string());
    note_active_sandbox_session(
        malvin::malvin_sandbox::take_sandbox_spawn_ticket().expect("ticket"),
        None,
        baseline,
        &work,
    )
    .expect("note");
    let lock = work
        .join(".malvin")
        .join("acp_spawn")
        .join("foreignslot.lock");
    std::fs::write(&lock, "424242").expect("overwrite with foreign pid");
    clear_active_sandbox_session();
    assert!(
        lock.exists(),
        "foreign lock must survive clear_active_sandbox_session"
    );
    let _ = std::fs::remove_file(&lock);
}

#[cfg(unix)]
#[test]
fn peer_acp_spawn_lock_clears_invalid_lock_file() {
    clear_active_sandbox_session();
    let work = fresh_workdir("malvin_peer_acp_spawn_lock_invalid");
    std::fs::create_dir_all(work.join(".malvin/acp_spawn")).expect("mkdir .malvin");
    malvin::set_active_acp_lock_slot("invalidslot".to_string());
    let lock = work
        .join(".malvin")
        .join("acp_spawn")
        .join("invalidslot.lock");
    std::fs::write(&lock, "not-a-pid").expect("write invalid lock");
    assert_no_peer_acp_spawn_lock(&work).expect("invalid lock cleared");
    assert!(!lock.exists(), "invalid lock file removed");
}

#[cfg(unix)]
#[test]
fn malvin_tokio_command_spawns_in_isolated_process_group() {
    let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
    rt.block_on(async {
        let mut cmd = malvin_tokio_command("sleep");
        cmd.arg("1");
        let mut child = cmd.spawn().expect("spawn");
        let pid = child.id().expect("child pid");
        assert_eq!(
            process_group_id(pid),
            pid,
            "tokio child should be its own process-group leader"
        );
        let _ = child.kill().await;
    });
}

#[cfg(unix)]
#[test]
fn malvin_std_command_spawns_in_isolated_process_group() {
    let mut cmd = malvin_std_command("sleep");
    cmd.arg("30");
    let mut child = cmd.spawn().expect("spawn");
    let pid = child.id();
    assert_eq!(
        process_group_id(pid),
        pid,
        "child should be its own process-group leader"
    );
    let _ = child.kill();
    let _ = child.wait();
}

#[test]
fn kiss_cov_malvin_spawn_rules_contract_symbols() {
    #[cfg(unix)]
    {}
}