//! P3 exit scenario across worker, check, and recorded fast-forward SIGKILL boundaries. use crate::common::copy_tree; use sausage::{harness::run_module, world::World}; use std::{ path::Path, process::Command, time::{Duration, Instant}, }; const BUNDLE: &str = include_str!("../../js/p3-test-bundle.js"); fn git(repo: &Path, args: &[&str]) -> anyhow::Result { let output = Command::new("git") .args(args) .current_dir(repo) .env("GIT_AUTHOR_NAME", "Test") .env("GIT_AUTHOR_EMAIL", "test@example.com") .env("GIT_COMMITTER_NAME", "Test") .env("GIT_COMMITTER_EMAIL", "test@example.com") .output()?; anyhow::ensure!( output.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&output.stderr) ); Ok(String::from_utf8(output.stdout)?.trim().into()) } fn prepare(dir: &Path, all_extensions: bool) -> anyhow::Result<()> { World::open(dir)?; let disk = dir.join("world"); let repo = disk.join("src/sausage"); let source = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); let target = crate::common::build_git(); std::fs::create_dir_all(disk.join("bin"))?; for binary in ["git", "tar"] { std::fs::copy( target.join(format!("wasm32-wasip2/release/{binary}.wasm")), disk.join(format!("bin/{binary}.wasm")), )?; } if !target.join("wasm32-wasip2/release/brush.wasm").exists() { anyhow::ensure!( crate::common::build_brush()?.success(), "brush build failed" ); } std::fs::copy( target.join("wasm32-wasip2/release/brush.wasm"), disk.join("bin/sh.wasm"), )?; if all_extensions { copy_tree(&source.join("extensions"), &repo.join("extensions"))?; } else { for extension in ["subagents", "models", "core"] { copy_tree( &source.join("extensions").join(extension), &repo.join("extensions").join(extension), )?; } } std::fs::create_dir_all(repo.join("extensions/probe"))?; std::fs::write( repo.join("extensions/probe/server.ts"), "export default host => { globalThis.p3Models=host.need({name:'models'}); globalThis.p3Subagents=host.need({name:'subagents'}); globalThis.p3Workspaces=host.need({name:'workspaces'}); globalThis.p3Host=host; return {name:'probe'}; }", )?; let roster: Vec = ["mainAgent", "coordinator", "worker", "reviewer"].into_iter().map(|role| serde_json::json!({ "name":role, "description":format!("P3 {role}"), "model":"faux/faux-1", "isolate":"none", "resident":role == "mainAgent", "prompt":format!("P3 role:{role}"), "denyTools":if role == "worker" || role == "reviewer" {vec!["subagent"]} else {vec![]} })).collect(); crate::common::write_config(dir, "mainAgent", serde_json::json!(roster))?; std::fs::write( repo.join("settings.json"), r#"{"lander":{"checks":["sh -c ': > /check-running; until test -f /check-release; do :; done; test -f feature.txt'"]}}"#, )?; let demo = disk.join("src/demo"); std::fs::create_dir_all(&demo)?; std::fs::write(demo.join("README.md"), "P3 demo\n")?; for repo in [&repo, &demo] { git(repo, &["init", "-b", "main"])?; git(repo, &["config", "user.name", "Test"])?; git(repo, &["config", "user.email", "test@example.com"])?; git(repo, &["add", "."])?; git(repo, &["commit", "--no-gpg-sign", "-m", "P3 fixture"])?; } Ok(()) } async fn run(dir: &Path, mode: &str) -> anyhow::Result<()> { let world = World::open(dir)?; let exec = sausage::process::exec(World { root: world.root.clone(), host_dir: world.host_dir.clone(), processes: world.processes.clone(), })?; run_module( world, exec, &format!("globalThis.p3Mode={mode:?};\n{BUNDLE}"), ) .await } #[tokio::test(flavor = "current_thread")] async fn scripted_model_replays() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; run(dir.path(), "unit").await } #[tokio::test(flavor = "current_thread")] async fn all_extensions_load() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; prepare(dir.path(), true)?; run(dir.path(), "load-all").await } #[tokio::test(flavor = "current_thread")] async fn branch_round_trip() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; prepare(dir.path(), true)?; run(dir.path(), "branch").await?; run(dir.path(), "branch-resume").await } #[tokio::test(flavor = "current_thread")] async fn restart_child() -> anyhow::Result<()> { if let Ok(dir) = std::env::var("P3_DIR") { run(Path::new(&dir), &std::env::var("P3_MODE")?).await?; } Ok(()) } fn recovery(point: &str) -> anyhow::Result<()> { let dir = tempfile::tempdir()?; prepare(dir.path(), true)?; if point != "check" { std::fs::write(dir.path().join("world/check-release"), "yes")?; } let spawn = |mode: &str| { Command::new(std::env::current_exe().unwrap()) .args(["--exact", "p3::restart_child", "--nocapture"]) .env("P3_DIR", dir.path()) .env("P3_MODE", mode) .spawn() }; let mut child = spawn(&format!("kill-{point}"))?; let marker = match point { "worker" => "worker-running", "check" => "check-running", "forward" => "forward-recorded", _ => unreachable!(), }; let deadline = Instant::now() + Duration::from_secs(120); while !dir.path().join("world").join(marker).exists() { if let Some(status) = child.try_wait()? { anyhow::bail!("P3 child exited before {point} marker: {status}"); } if Instant::now() > deadline { child.kill()?; child.wait()?; anyhow::bail!("P3 did not reach {point} marker"); } std::thread::yield_now(); } child.kill()?; let killed = child.wait()?; use std::os::unix::process::ExitStatusExt; assert_eq!(killed.signal(), Some(9)); let demo = dir.path().join("world/src/demo"); assert_eq!( git(&demo, &["rev-list", "--count", "main"])?, if point == "forward" { "3" } else { "1" } ); if point == "forward" { let recorded: serde_json::Value = serde_json::from_str(&std::fs::read_to_string( dir.path().join("world/forward-recorded"), )?)?; assert_eq!( recorded[0]["landed"]["sha"].as_str(), Some(git(&demo, &["rev-parse", "main"])?.as_str()) ); } std::fs::write(dir.path().join("world/check-release"), "yes")?; // A second reopen proves that idle recovery does not duplicate reports, cards, events, or commits. for _ in 0..2 { let mut child = spawn("resume")?; let deadline = Instant::now() + Duration::from_secs(120); loop { if let Some(status) = child.try_wait()? { anyhow::ensure!(status.success(), "P3 recovery failed: {status}"); break; } if Instant::now() > deadline { child.kill()?; child.wait()?; anyhow::bail!("P3 recovery did not reach idle"); } std::thread::yield_now(); } let demo = dir.path().join("world/src/demo"); assert_eq!(git(&demo, &["rev-list", "--count", "main"])?, "3"); assert_eq!( git(&demo, &["log", "--format=%s", "main"])? .lines() .filter(|line| *line == "P3 feature") .count(), 1 ); assert_eq!(git(&demo, &["status", "--porcelain"])?, ""); assert_eq!( std::fs::read_to_string(demo.join("feature.txt"))?, "P3 feature\n" ); } Ok(()) } #[test] fn worker_sigkill_recovery() -> anyhow::Result<()> { recovery("worker") } #[test] fn lander_check_sigkill_recovery() -> anyhow::Result<()> { recovery("check") } #[test] fn target_fast_forward_sigkill_recovery() -> anyhow::Result<()> { recovery("forward") }