248 lines
8.6 KiB
Rust
248 lines
8.6 KiB
Rust
//! P3 exit scenario across worker, check, and recorded fast-forward SIGKILL boundaries.
|
|
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<String> {
|
|
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 copy_tree(source: &Path, destination: &Path) -> anyhow::Result<()> {
|
|
std::fs::create_dir_all(destination)?;
|
|
for entry in std::fs::read_dir(source)? {
|
|
let entry = entry?;
|
|
let target = destination.join(entry.file_name());
|
|
if entry.file_type()?.is_dir() {
|
|
copy_tree(&entry.path(), &target)?;
|
|
} else {
|
|
std::fs::copy(entry.path(), target)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
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"))?;
|
|
std::fs::copy(
|
|
target.join("wasm32-wasip2/release/git.wasm"),
|
|
disk.join("bin/git.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'}; }",
|
|
)?;
|
|
std::fs::create_dir_all(repo.join("agents"))?;
|
|
std::fs::write(
|
|
repo.join("agents/models.json"),
|
|
r#"{"default":{"model":"faux/faux-1"}}"#,
|
|
)?;
|
|
for role in ["ultimate", "coordinator", "worker", "reviewer"] {
|
|
let denial = if role == "worker" || role == "reviewer" {
|
|
"denyTools: [subagent]\n"
|
|
} else {
|
|
""
|
|
};
|
|
std::fs::write(
|
|
repo.join(format!("agents/{role}.md")),
|
|
format!(
|
|
"---\nname: {role}\ndescription: P3 {role}\nisolate: none\n{denial}{}---\nP3 role:{role}\n",
|
|
if role == "ultimate" {
|
|
"resident: true\n"
|
|
} else {
|
|
""
|
|
}
|
|
),
|
|
)?;
|
|
}
|
|
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")
|
|
}
|