use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use serde_json::Value;
use serial_test::file_serial;
use tempfile::TempDir;
mod common;
use common::TestHome;
struct AgentGuard {
pid: i32,
}
impl Drop for AgentGuard {
fn drop(&mut self) {
if self.pid > 0 {
unsafe { libc::kill(self.pid, libc::SIGKILL) };
}
}
}
fn write_exec(path: &Path, body: &str) {
std::fs::write(path, body).unwrap();
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).unwrap();
}
fn write_create_sh(
scratch: &Path,
worktree: &Path,
agent_pid_file: &Path,
branch: &str,
) -> PathBuf {
let p = scratch.join("fake-create.sh");
let body = format!(
r#"#!/bin/bash
# E2E stub create.sh — spawn a minimal agent, record its pid, emit envelope.
# Redirect every std fd away from create.sh's stdout pipe so `run create`
# reads our envelope and then sees EOF immediately (the agent does NOT hold
# the pipe open for its whole sleep).
bash -c 'echo done; exec sleep 120' </dev/null >/dev/null 2>&1 &
agent_pid=$!
echo "$agent_pid" > '{pidfile}'
cat <<EOF
{{"schema_version":1,"type":"spinoff","branch":"{branch}","worktree_path":"{worktree}","tmux_window":"{branch}","agent_pid_hint":$agent_pid,"workmux_session":"e2e","tmux_socket":null,"tmux_session":"e2e","tmux_window_id":"@1"}}
EOF
"#,
pidfile = agent_pid_file.display(),
branch = branch,
worktree = worktree.display(),
);
write_exec(&p, &body);
p
}
fn write_merge_sh(dir: &Path) -> PathBuf {
let p = dir.join("fake-merge.sh");
let log = dir.join("merge.log");
let body = format!(
"#!/bin/bash\nprintf '%s ' \"$@\" >> '{log}'\nprintf '\\n' >> '{log}'\nexit 0\n",
log = log.display(),
);
write_exec(&p, &body);
p
}
fn read_events(events: &Path) -> Vec<Value> {
std::fs::read_to_string(events)
.unwrap_or_default()
.lines()
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
.collect()
}
fn event_kinds(events: &Path) -> Vec<String> {
read_events(events)
.into_iter()
.filter_map(|v| v["kind"].as_str().map(str::to_string))
.collect()
}
fn wait_for_event(events: &Path, kind: &str, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if event_kinds(events).iter().any(|k| k == kind) {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn run_ok(cmd: &mut Command) -> Value {
let out = cmd.output().expect("spawn");
assert!(
out.status.success(),
"exit={:?} stderr={}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("stdout is valid JSON")
}
fn read_supervisor_pid(pid_file: &Path) -> Option<i32> {
let s = std::fs::read_to_string(pid_file).ok()?;
s.split_whitespace().next()?.parse::<i32>().ok()
}
fn wait_for_process_gone(pid: i32, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if unsafe { libc::kill(pid, 0) } != 0 {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(20));
}
}
fn wait_for_manifest_status(manifest: &Path, want: &str, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if let Ok(bytes) = std::fs::read(manifest) {
if let Ok(v) = serde_json::from_slice::<Value>(&bytes) {
if v["status"] == want {
return true;
}
}
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(50));
}
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn spinoff_round_trip_reaches_done_and_tears_down() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let worktree = scratch.path().join("worktree");
std::fs::create_dir_all(&worktree).unwrap();
let agent_pid_file = scratch.path().join("agent.pid");
let branch = "wt/e2e-spinoff";
let create_sh = write_create_sh(scratch.path(), &worktree, &agent_pid_file, branch);
let merge_sh = write_merge_sh(scratch.path());
let no_tmux = scratch.path().join("no-such-tmux");
let no_git = scratch.path().join("no-such-git");
let created = run_ok(
Command::new(env!("CARGO_BIN_EXE_orchestratectl"))
.env("ORCHESTRATECTL_HOME", home.path())
.env("OCTL_CREATE_SH", &create_sh)
.env("TMUX_BIN", &no_tmux)
.env("GIT_BIN", &no_git)
.args([
"--output",
"json",
"run",
"create",
"--kind",
"spinoff",
"--headless",
"--title",
"e2e",
"--task",
"echo done",
]),
);
let run_id = created["data"]["run_id"].as_str().unwrap().to_string();
assert_eq!(created["data"]["kind"], "spinoff");
assert_eq!(created["data"]["node_id"], "n-0001");
assert_eq!(created["data"]["lifecycle"], "autonomous");
let agent_pid: i32 = std::fs::read_to_string(&agent_pid_file)
.expect("create.sh recorded the agent pid")
.trim()
.parse()
.expect("agent pid is an integer");
let _agent = AgentGuard { pid: agent_pid };
let events = home.path().join("runs").join(&run_id).join("events.jsonl");
assert!(
wait_for_event(&events, "supervisor.started", Duration::from_secs(15)),
"supervisor never emitted supervisor.started; events: {:?}",
event_kinds(&events)
);
let merged = run_ok(
Command::new(env!("CARGO_BIN_EXE_orchestratectl"))
.env("ORCHESTRATECTL_HOME", home.path())
.env("OCTL_MERGE_SH", &merge_sh)
.args(["--output", "json", "run", "merge", &run_id]),
);
assert_eq!(merged["data"]["merged"], true);
assert_eq!(merged["data"]["branch"], branch);
assert!(
wait_for_event(&events, "supervisor.exited", Duration::from_secs(30)),
"supervisor never exited; events: {:?}",
event_kinds(&events)
);
let kinds = event_kinds(&events);
let expected = [
"run.created",
"node.created",
"supervisor.started",
"node.report",
"run.status",
"supervisor.exited",
];
let mut idx = 0usize;
for k in &kinds {
if idx < expected.len() && k == expected[idx] {
idx += 1;
}
}
assert_eq!(
idx,
expected.len(),
"events did not contain the canonical lifecycle sequence {expected:?} in order; got {kinds:?}"
);
let run_status_done = read_events(&events)
.into_iter()
.any(|v| v["kind"] == "run.status" && v["data"]["status"] == "done");
assert!(
run_status_done,
"run.status was not `done`; events: {kinds:?}"
);
let report_via_merge = read_events(&events)
.into_iter()
.any(|v| v["kind"] == "node.report" && v["data"]["via"] == "explicit-merge");
assert!(report_via_merge, "node.report was not via explicit-merge");
let manifest: Value = serde_json::from_slice(
&std::fs::read(home.path().join("runs").join(&run_id).join("manifest.json")).unwrap(),
)
.unwrap();
assert_eq!(manifest["status"], "done", "manifest status: {manifest}");
assert_eq!(manifest["kind"], "spinoff");
assert_eq!(manifest["lifecycle"], "autonomous");
assert_eq!(
manifest["node_count"].as_u64(),
Some(1),
"exactly one node: {manifest}"
);
let node: Value = serde_json::from_slice(
&std::fs::read(
home.path()
.join("runs")
.join(&run_id)
.join("nodes")
.join("n-0001.json"),
)
.unwrap(),
)
.unwrap();
assert_eq!(node["status"], "done", "node status: {node}");
let pid_file = home
.path()
.join("runs")
.join(&run_id)
.join("supervisor.pid");
let deadline = Instant::now() + Duration::from_secs(5);
while pid_file.exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
}
assert!(
!pid_file.exists(),
"supervisor.pid should be removed on clean exit"
);
}
fn git(cwd: &Path, args: &[&str]) {
let ok = Command::new("git")
.current_dir(cwd)
.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("spawn git")
.success();
assert!(ok, "git {args:?} failed in {}", cwd.display());
}
fn init_real_repo_with_committed_work(scratch: &Path, branch: &str) -> (PathBuf, PathBuf) {
let repo = scratch.join("repo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
git(&repo, &["config", "user.email", "t@example.com"]);
git(&repo, &["config", "user.name", "t"]);
std::fs::write(repo.join("README"), "x").unwrap();
git(&repo, &["add", "-A"]);
git(&repo, &["commit", "-qm", "init"]);
let wt = scratch.join("agent-wt");
git(
&repo,
&["worktree", "add", "-q", "-b", branch, wt.to_str().unwrap()],
);
std::fs::write(wt.join("fix.rs"), "agent work").unwrap();
git(&wt, &["add", "-A"]);
git(&wt, &["commit", "-qm", "agent work"]);
(repo, wt)
}
fn commits_ahead_of_main(repo: &Path, branch: &str) -> usize {
let out = Command::new("git")
.current_dir(repo)
.args(["rev-list", "--count", &format!("main..{branch}")])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().parse().unwrap()
}
fn branch_exists(repo: &Path, branch: &str) -> bool {
Command::new("git")
.current_dir(repo)
.args(["rev-parse", "--verify", "--quiet", branch])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.unwrap()
.success()
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn blocked_report_preserves_branch_and_worktree_e2e() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let branch = "wt/e2e-blocked";
let (repo, worktree) = init_real_repo_with_committed_work(scratch.path(), branch);
assert_eq!(commits_ahead_of_main(&repo, branch), 1);
let agent_pid_file = scratch.path().join("agent.pid");
let create_sh = write_create_sh(scratch.path(), &worktree, &agent_pid_file, branch);
let no_tmux = scratch.path().join("no-such-tmux");
let created = run_ok(
Command::new(env!("CARGO_BIN_EXE_orchestratectl"))
.env("ORCHESTRATECTL_HOME", home.path())
.env("OCTL_CREATE_SH", &create_sh)
.env("TMUX_BIN", &no_tmux)
.env_remove("GIT_BIN")
.args([
"--output",
"json",
"run",
"create",
"--kind",
"bugfix",
"--headless",
"--title",
"e2e-blocked",
"--task",
"investigate",
]),
);
let run_id = created["data"]["run_id"].as_str().unwrap().to_string();
let agent_pid: i32 = std::fs::read_to_string(&agent_pid_file)
.expect("create.sh recorded the agent pid")
.trim()
.parse()
.expect("agent pid is an integer");
let _agent = AgentGuard { pid: agent_pid };
let run_root = home.path().join("runs").join(&run_id);
let events = run_root.join("events.jsonl");
let manifest = run_root.join("manifest.json");
assert!(
wait_for_event(&events, "supervisor.started", Duration::from_secs(15)),
"supervisor never started; events: {:?}",
event_kinds(&events)
);
let report_file = scratch.path().join("blocked.json");
std::fs::write(
&report_file,
r#"{"success": false, "summary": "needs the user's sudo",
"discussion_items": [{"topic": "blocked", "detail": "need a human"}]}"#,
)
.unwrap();
run_ok(
Command::new(env!("CARGO_BIN_EXE_orchestratectl"))
.env("ORCHESTRATECTL_HOME", home.path())
.args([
"--output",
"json",
"node",
"report",
&run_id,
"n-0001",
"--from-file",
report_file.to_str().unwrap(),
]),
);
assert!(
wait_for_manifest_status(&manifest, "failed", Duration::from_secs(30)),
"run never rolled up to failed; events: {:?}",
event_kinds(&events)
);
assert!(
wait_for_event(&events, "supervisor.exited", Duration::from_secs(30)),
"supervisor never exited; events: {:?}",
event_kinds(&events)
);
assert!(
branch_exists(&repo, branch),
"blocked terminal report must leave the branch for the human"
);
assert_eq!(
commits_ahead_of_main(&repo, branch),
1,
"the agent's committed work must survive on the preserved branch"
);
assert!(
worktree.exists(),
"blocked path should preserve the worktree too"
);
let preserved = read_events(&events)
.into_iter()
.any(|v| v["kind"] == "cleanup.branch_preserved" && v["data"]["branch"] == branch);
assert!(
preserved,
"expected a cleanup.branch_preserved audit event; events: {:?}",
event_kinds(&events)
);
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn merge_path_deletes_branch_e2e() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let branch = "wt/e2e-merge";
let (repo, worktree) = init_real_repo_with_committed_work(scratch.path(), branch);
let agent_pid_file = scratch.path().join("agent.pid");
let create_sh = write_create_sh(scratch.path(), &worktree, &agent_pid_file, branch);
let merge_sh = write_merge_sh(scratch.path());
let no_tmux = scratch.path().join("no-such-tmux");
let created = run_ok(
Command::new(env!("CARGO_BIN_EXE_orchestratectl"))
.env("ORCHESTRATECTL_HOME", home.path())
.env("OCTL_CREATE_SH", &create_sh)
.env("TMUX_BIN", &no_tmux)
.env_remove("GIT_BIN")
.args([
"--output",
"json",
"run",
"create",
"--kind",
"spinoff",
"--headless",
"--title",
"e2e-merge",
"--task",
"echo done",
]),
);
let run_id = created["data"]["run_id"].as_str().unwrap().to_string();
let agent_pid: i32 = std::fs::read_to_string(&agent_pid_file)
.expect("create.sh recorded the agent pid")
.trim()
.parse()
.expect("agent pid is an integer");
let _agent = AgentGuard { pid: agent_pid };
let run_root = home.path().join("runs").join(&run_id);
let events = run_root.join("events.jsonl");
let manifest = run_root.join("manifest.json");
assert!(
wait_for_event(&events, "supervisor.started", Duration::from_secs(15)),
"supervisor never started; events: {:?}",
event_kinds(&events)
);
let merged = run_ok(
Command::new(env!("CARGO_BIN_EXE_orchestratectl"))
.env("ORCHESTRATECTL_HOME", home.path())
.env("OCTL_MERGE_SH", &merge_sh)
.args(["--output", "json", "run", "merge", &run_id]),
);
assert_eq!(merged["data"]["merged"], true);
assert!(
wait_for_manifest_status(&manifest, "done", Duration::from_secs(30)),
"run never rolled up to done; events: {:?}",
event_kinds(&events)
);
assert!(
wait_for_event(&events, "supervisor.exited", Duration::from_secs(30)),
"supervisor never exited; events: {:?}",
event_kinds(&events)
);
assert!(
!branch_exists(&repo, branch),
"explicit-merge path must still delete the branch"
);
assert!(
!worktree.exists(),
"explicit-merge path must still remove the worktree"
);
assert!(
read_events(&events)
.into_iter()
.all(|v| v["kind"] != "cleanup.branch_preserved"),
"the merge path must not preserve (must delete) the branch"
);
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn swallowed_agent_died_then_merge_reattaches_and_tears_down() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let branch = "wt/e2e-swallowed";
let (repo, worktree) = init_real_repo_with_committed_work(scratch.path(), branch);
let agent_pid_file = scratch.path().join("agent.pid");
let create_sh = write_create_sh(scratch.path(), &worktree, &agent_pid_file, branch);
let merge_sh = write_merge_sh(scratch.path());
let no_tmux = scratch.path().join("no-such-tmux");
let created = run_ok(
Command::new(env!("CARGO_BIN_EXE_orchestratectl"))
.env("ORCHESTRATECTL_HOME", home.path())
.env("OCTL_CREATE_SH", &create_sh)
.env("TMUX_BIN", &no_tmux)
.env_remove("GIT_BIN")
.args([
"--output",
"json",
"run",
"create",
"--kind",
"spinoff",
"--headless",
"--title",
"e2e-swallowed",
"--task",
"echo done",
]),
);
let run_id = created["data"]["run_id"].as_str().unwrap().to_string();
let agent_pid: i32 = std::fs::read_to_string(&agent_pid_file)
.expect("create.sh recorded the agent pid")
.trim()
.parse()
.expect("agent pid is an integer");
let _agent = AgentGuard { pid: agent_pid };
let run_root = home.path().join("runs").join(&run_id);
let events = run_root.join("events.jsonl");
let pid_file = run_root.join("supervisor.pid");
assert!(
wait_for_event(&events, "supervisor.started", Duration::from_secs(15)),
"supervisor never started; events: {:?}",
event_kinds(&events)
);
let first_pid = read_supervisor_pid(&pid_file).expect("supervisor.pid recorded a pid");
let report = scratch.path().join("agent-died.json");
std::fs::write(
&report,
r#"{"success": false, "failed": true, "reason": "agent-died", "summary": "watchdog false positive"}"#,
)
.unwrap();
run_ok(
Command::new(env!("CARGO_BIN_EXE_orchestratectl"))
.env("ORCHESTRATECTL_HOME", home.path())
.env("TMUX_BIN", &no_tmux)
.env_remove("GIT_BIN")
.args([
"--output",
"json",
"node",
"report",
&run_id,
"n-0001",
"--from-file",
report.to_str().unwrap(),
]),
);
assert!(
wait_for_event(&events, "supervisor.exited", Duration::from_secs(30)),
"first supervisor never exited after the agent-died terminal; events: {:?}",
event_kinds(&events)
);
assert!(
wait_for_process_gone(first_pid, Duration::from_secs(10)),
"first supervisor pid {first_pid} still alive"
);
assert!(
branch_exists(&repo, branch),
"the blocked terminal must PRESERVE the branch (not tear it down)"
);
assert!(
worktree.exists(),
"the blocked terminal must preserve the worktree"
);
assert!(
read_events(&events)
.into_iter()
.any(|v| v["kind"] == "cleanup.branch_preserved"),
"expected a cleanup.branch_preserved on the pre-merge terminal; events: {:?}",
event_kinds(&events)
);
let merged = run_ok(
Command::new(env!("CARGO_BIN_EXE_orchestratectl"))
.env("ORCHESTRATECTL_HOME", home.path())
.env("OCTL_MERGE_SH", &merge_sh)
.env("TMUX_BIN", &no_tmux)
.env_remove("GIT_BIN")
.args(["--output", "json", "run", "merge", &run_id]),
);
assert_eq!(merged["data"]["merged"], true);
assert_eq!(
merged["data"]["supervisor"]["state"], "reattached",
"the swallowed path must reattach a supervisor to tear down, got: {}",
merged["data"]["supervisor"]
);
let deadline = Instant::now() + Duration::from_secs(30);
while (branch_exists(&repo, branch) || worktree.exists()) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(100));
}
assert!(
!branch_exists(&repo, branch),
"the reattached supervisor must delete the adopted-merge branch; events: {:?}",
event_kinds(&events)
);
assert!(
!worktree.exists(),
"the reattached supervisor must remove the worktree; events: {:?}",
event_kinds(&events)
);
assert!(
event_kinds(&events)
.iter()
.any(|k| k == "supervisor.reattached"),
"expected a supervisor.reattached event; got {:?}",
event_kinds(&events)
);
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn merge_reattaches_and_warns_when_supervisor_dead() {
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let worktree = scratch.path().join("worktree");
std::fs::create_dir_all(&worktree).unwrap();
let agent_pid_file = scratch.path().join("agent.pid");
let branch = "wt/e2e-dead-supervisor";
let create_sh = write_create_sh(scratch.path(), &worktree, &agent_pid_file, branch);
let merge_sh = write_merge_sh(scratch.path());
let no_tmux = scratch.path().join("no-such-tmux");
let no_git = scratch.path().join("no-such-git");
let created = run_ok(
Command::new(env!("CARGO_BIN_EXE_orchestratectl"))
.env("ORCHESTRATECTL_HOME", home.path())
.env("OCTL_CREATE_SH", &create_sh)
.env("TMUX_BIN", &no_tmux)
.env("GIT_BIN", &no_git)
.args([
"--output",
"json",
"run",
"create",
"--kind",
"spinoff",
"--headless",
"--title",
"e2e-dead",
"--task",
"echo done",
]),
);
let run_id = created["data"]["run_id"].as_str().unwrap().to_string();
let agent_pid: i32 = std::fs::read_to_string(&agent_pid_file)
.expect("create.sh recorded the agent pid")
.trim()
.parse()
.expect("agent pid is an integer");
let _agent = AgentGuard { pid: agent_pid };
let run_root = home.path().join("runs").join(&run_id);
let events = run_root.join("events.jsonl");
let pid_file = run_root.join("supervisor.pid");
let manifest = run_root.join("manifest.json");
assert!(
wait_for_event(&events, "supervisor.started", Duration::from_secs(15)),
"supervisor never started; events: {:?}",
event_kinds(&events)
);
let dead_pid = read_supervisor_pid(&pid_file).expect("supervisor.pid recorded a pid");
unsafe { libc::kill(dead_pid, libc::SIGKILL) };
assert!(
wait_for_process_gone(dead_pid, Duration::from_secs(10)),
"killed supervisor pid {dead_pid} did not exit"
);
let merged = run_ok(
Command::new(env!("CARGO_BIN_EXE_orchestratectl"))
.env("ORCHESTRATECTL_HOME", home.path())
.env("OCTL_MERGE_SH", &merge_sh)
.env("TMUX_BIN", &no_tmux)
.env("GIT_BIN", &no_git)
.args(["--output", "json", "run", "merge", &run_id]),
);
assert_eq!(merged["data"]["merged"], true);
assert_eq!(merged["data"]["branch"], branch);
assert_eq!(
merged["data"]["supervisor"]["state"], "reattached",
"merge on a dead supervisor must record a reattached outcome, got: {}",
merged["data"]["supervisor"]
);
let warnings = merged["warnings"]
.as_array()
.expect("envelope carries a warnings array");
assert!(
warnings
.iter()
.any(|w| w.as_str().is_some_and(|s| s.contains("supervisor")
&& (s.contains("restarted") || s.contains("run reattach")))),
"merge on a dead supervisor must warn about the restart/recovery, got: {warnings:?}"
);
assert!(
wait_for_manifest_status(&manifest, "done", Duration::from_secs(30)),
"auto-reattached supervisor never rolled the run up to done; events: {:?}",
event_kinds(&events)
);
let kinds = event_kinds(&events);
assert!(
kinds.iter().any(|k| k == "supervisor.reattached"),
"expected a supervisor.reattached event after auto-reattach; got {kinds:?}"
);
let recovered_via_stale_marker = read_events(&events)
.into_iter()
.any(|v| v["kind"] == "supervisor.exited" && v["data"]["reason"] == "stale-on-reattach");
assert!(
recovered_via_stale_marker,
"expected supervisor.exited{{reason:stale-on-reattach}} proving the reattach recovery path ran; got {kinds:?}"
);
}