use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
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
bash -c '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 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()
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn run_create_fails_loud_when_supervisor_never_confirms() {
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/creation-fail-loud";
let create_sh = write_create_sh(scratch.path(), &worktree, &agent_pid_file, branch);
let no_git = scratch.path().join("no-such-git");
let out = Command::new(env!("CARGO_BIN_EXE_orchestratectl"))
.env("ORCHESTRATECTL_HOME", home.path())
.env("OCTL_CREATE_SH", &create_sh)
.env("OCTL_SUPERVISE_BIN", "/usr/bin/false")
.env("GIT_BIN", &no_git)
.args([
"--output",
"json",
"run",
"create",
"--kind",
"spinoff",
"--headless",
"--title",
"fail-loud",
"--task",
"echo done",
])
.output()
.expect("spawn run create");
let _agent = std::fs::read_to_string(&agent_pid_file)
.ok()
.and_then(|s| s.trim().parse::<i32>().ok())
.map(|pid| AgentGuard { pid });
assert!(
!out.status.success(),
"run create must fail loudly, not report a bogus success"
);
let stderr = String::from_utf8_lossy(&out.stderr);
let env: Value = serde_json::from_str(stderr.trim().lines().last().unwrap_or(""))
.unwrap_or_else(|_| panic!("stderr is not a JSON error envelope: {stderr}"));
assert_eq!(env["error"]["code"], "supervisor_spawn_failed");
let run_id = env["error"]["invalid_value"]
.as_str()
.expect("error envelope carries the run id in invalid_value")
.to_string();
let run_dir = home.path().join("runs").join(&run_id);
let manifest: Value =
serde_json::from_slice(&std::fs::read(run_dir.join("manifest.json")).unwrap()).unwrap();
assert_eq!(manifest["status"], "pending");
assert_eq!(manifest["node_count"], 1);
let log = std::fs::read_to_string(run_dir.join("supervisor.stderr.log")).unwrap_or_default();
assert!(
log.contains("readiness pipe closed") || log.contains("died during init"),
"supervisor.stderr.log must record the spawn failure reason, got: {log:?}"
);
let kinds: Vec<String> = read_events(&run_dir.join("events.jsonl"))
.into_iter()
.filter_map(|v| v["kind"].as_str().map(str::to_string))
.collect();
assert!(
kinds.contains(&"node.created".to_string()),
"kinds: {kinds:?}"
);
}