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) };
}
}
}
struct TmuxServerGuard {
tmux: PathBuf,
socket: PathBuf,
}
impl Drop for TmuxServerGuard {
fn drop(&mut self) {
let _ = Command::new(&self.tmux)
.args(["-S", self.socket.to_str().unwrap(), "kill-server"])
.output();
}
}
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 which_tmux() -> Option<PathBuf> {
let out = Command::new("sh")
.args(["-c", "command -v tmux"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let p = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!p.is_empty()).then(|| PathBuf::from(p))
}
fn write_create_sh(
scratch: &Path,
tmux: &Path,
socket: &Path,
worktree: &Path,
agent_pid_file: &Path,
session: &str,
branch: &str,
) -> PathBuf {
let p = scratch.join("fake-create.sh");
let body = format!(
r#"#!/bin/bash
set -e
TMUX={tmux}
SOCK={socket}
# Bootstrap the headless session exactly as the real create.sh does: a detached
# session whose lone window is the synthetic default shell (named `zsh` here so
# the assertion is deterministic across CI shells). Idempotent — a second spawn
# into the same session is a no-op (`|| true`), mirroring create.sh.
"$TMUX" -S "$SOCK" new-session -d -s {session} -n zsh "sleep 600" 2>/dev/null || true
# A long-lived agent process so PID liveness keeps the node Alive until the
# merge terminalizes it (the agent does NOT hold create.sh's stdout pipe open).
bash -c 'echo done; exec sleep 120' </dev/null >/dev/null 2>&1 &
agent_pid=$!
echo "$agent_pid" > '{pidfile}'
# The agent's own tmux window, alongside the bootstrap shell. Capture its id.
wid=$("$TMUX" -S "$SOCK" new-window -t {session} -n "agent-{branch}" -P -F '#{{window_id}}' "sleep 600")
cat <<EOF
{{"schema_version":1,"type":"spinoff","branch":"{branch}","worktree_path":"{worktree}","tmux_window":"agent-{branch}","agent_pid_hint":$agent_pid,"workmux_session":"{session}","tmux_socket":"{socket}","tmux_session":"{session}","tmux_window_id":"$wid"}}
EOF
"#,
tmux = tmux.display(),
socket = socket.display(),
session = session,
branch = branch,
pidfile = agent_pid_file.display(),
worktree = worktree.display(),
);
write_exec(&p, &body);
p
}
fn write_merge_sh(dir: &Path) -> PathBuf {
let p = dir.join("fake-merge.sh");
write_exec(&p, "#!/bin/bash\nexit 0\n");
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 list_sessions(tmux: &Path, socket: &Path) -> Vec<String> {
let out = Command::new(tmux)
.args([
"-S",
socket.to_str().unwrap(),
"list-sessions",
"-F",
"#{session_name}",
])
.output()
.expect("spawn tmux list-sessions");
if !out.status.success() {
return Vec::new();
}
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn headless_session_is_torn_down_after_last_managed_run() {
let Some(tmux) = which_tmux() else {
eprintln!(
"skipping headless_session_is_torn_down_after_last_managed_run: tmux not on PATH"
);
return;
};
let home = TestHome::new();
let scratch = TempDir::new().unwrap();
let socket = scratch.path().join("tmux.sock");
let worktree = scratch.path().join("worktree");
std::fs::create_dir_all(&worktree).unwrap();
let agent_pid_file = scratch.path().join("agent.pid");
let session = "headless";
let branch = "wt/headless-teardown";
let _server = TmuxServerGuard {
tmux: tmux.clone(),
socket: socket.clone(),
};
let create_sh = write_create_sh(
scratch.path(),
&tmux,
&socket,
&worktree,
&agent_pid_file,
session,
branch,
);
let merge_sh = write_merge_sh(scratch.path());
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", &tmux)
.env("GIT_BIN", &no_git)
.args([
"--output",
"json",
"run",
"create",
"--kind",
"spinoff",
"--headless",
"--title",
"headless-teardown",
"--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 events = home.path().join("runs").join(&run_id).join("events.jsonl");
assert!(
wait_for_event(&events, "supervisor.started", Duration::from_secs(15)),
"supervisor never started; events: {:?}",
event_kinds(&events)
);
assert!(
list_sessions(&tmux, &socket).iter().any(|s| s == session),
"the headless session should exist while the run is live"
);
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", &tmux)
.env("GIT_BIN", &no_git)
.args(["--output", "json", "run", "merge", &run_id]),
);
assert_eq!(merged["data"]["merged"], true);
assert!(
wait_for_event(&events, "supervisor.exited", Duration::from_secs(30)),
"supervisor never exited; events: {:?}",
event_kinds(&events)
);
let deadline = Instant::now() + Duration::from_secs(5);
let mut sessions = list_sessions(&tmux, &socket);
while sessions.iter().any(|s| s == session) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
sessions = list_sessions(&tmux, &socket);
}
assert!(
!sessions.iter().any(|s| s == session),
"the empty `{session}` session must be torn down after the last managed run; \
tmux list-sessions still shows: {sessions:?}"
);
let killed = read_events(&events)
.into_iter()
.any(|v| v["kind"] == "cleanup.session_killed" && v["data"]["session"] == session);
assert!(
killed,
"expected a cleanup.session_killed audit event; events: {:?}",
event_kinds(&events)
);
}