use std::path::Path;
use std::process::Command;
use std::time::Duration;
use serde_json::Value;
use serial_test::file_serial;
use tempfile::TempDir;
mod common;
use common::TestHome;
fn bin(home: &TempDir) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_orchestratectl"));
c.env("ORCHESTRATECTL_HOME", home.path());
c.env("OCTL_TEST_SKIP_MATERIALIZE", "1");
c.env("TMUX_BIN", "/usr/bin/true");
c
}
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_events(events: &Path) -> Vec<Value> {
std::fs::read_to_string(events)
.unwrap_or_default()
.lines()
.map(|l| serde_json::from_str::<Value>(l).unwrap())
.collect()
}
fn count_kind(events: &Path, kind: &str) -> usize {
read_events(events)
.into_iter()
.filter(|v| v["kind"] == kind)
.count()
}
fn count_kind_lenient(events: &Path, kind: &str) -> usize {
std::fs::read_to_string(events)
.unwrap_or_default()
.lines()
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
.filter(|v| v["kind"] == kind)
.count()
}
const POLL_DEADLINE: Duration = Duration::from_secs(30);
fn poll_until<F: FnMut() -> bool>(deadline: Duration, mut predicate: F) -> bool {
let start = std::time::Instant::now();
loop {
if predicate() {
return true;
}
match deadline.checked_sub(start.elapsed()) {
Some(remaining) if !remaining.is_zero() => {
std::thread::sleep(remaining.min(Duration::from_millis(50)));
}
_ => return false,
}
}
}
fn wait_for_kind(events: &Path, kind: &str, want: usize) -> usize {
let mut seen = 0;
poll_until(POLL_DEADLINE, || {
seen = count_kind_lenient(events, kind);
seen >= want
});
seen
}
fn create_run(home: &TempDir, kind: &str, title: &str) -> String {
let v = run_ok(bin(home).args([
"--output", "json", "run", "create", "--kind", kind, "--title", title,
]));
v["data"]["run_id"].as_str().unwrap().to_string()
}
fn run_dir(home: &TempDir, run_id: &str) -> std::path::PathBuf {
home.path().join("runs").join(run_id)
}
fn fake_recorder(dir: &Path, name: &str, log: &str, extra: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let p = dir.join(name);
let log_path = dir.join(log);
let log = log_path.to_str().unwrap();
let body = format!(
"#!/bin/bash\nprintf '%s ' \"$@\" >> '{log}'\nprintf '\\n' >> '{log}'\n{extra}\nexit 0\n",
);
std::fs::write(&p, body).unwrap();
let mut perms = std::fs::metadata(&p).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&p, perms).unwrap();
p
}
fn fake_tmux_recorder(dir: &Path) -> std::path::PathBuf {
fake_recorder(dir, "fake-tmux.sh", "tmux.log", "")
}
fn fake_git_recorder(dir: &Path) -> std::path::PathBuf {
fake_recorder(
dir,
"fake-git.sh",
"git.log",
"case \"$*\" in *'worktree list'*) echo 'worktree /fake/main';; esac",
)
}
fn log_contents(dir: &Path, log: &str) -> String {
std::fs::read_to_string(dir.join(log)).unwrap_or_default()
}
struct AgentGuard {
child: std::process::Child,
}
impl Drop for AgentGuard {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn forge_live_worker_node(home: &TempDir, run_id: &str) -> AgentGuard {
let child = Command::new("sleep")
.arg("120")
.spawn()
.expect("spawn agent");
let agent_pid = child.id();
let node = home.path().join(format!("node-live-{run_id}.json"));
std::fs::write(
&node,
format!(
r#"{{"kind":"spinoff","task":"x","worktree_path":"/fake/wt","branch":"wt/test-x","tmux_session":"octl","tmux_window_id":"@42","agent_pid":{agent_pid}}}"#
),
)
.unwrap();
run_ok(bin(home).args([
"--output",
"json",
"event",
"create",
run_id,
"--kind",
"node.created",
"--node-id",
"n-0001",
"--from-file",
node.to_str().unwrap(),
]));
AgentGuard { child }
}
fn forge_terminal_worker_node(home: &TempDir, run_id: &str, kind: &str, report: &str) {
let node = home.path().join(format!("node-{run_id}.json"));
std::fs::write(
&node,
format!(
r#"{{"kind":"{kind}","task":"x","worktree_path":"/fake/wt","branch":"wt/test-x","tmux_session":"octl","tmux_window_id":"@42"}}"#
),
)
.unwrap();
run_ok(bin(home).args([
"--output",
"json",
"event",
"create",
run_id,
"--kind",
"node.created",
"--node-id",
"n-0001",
"--from-file",
node.to_str().unwrap(),
]));
let report_file = home.path().join(format!("report-{run_id}.json"));
std::fs::write(&report_file, report).unwrap();
run_ok(bin(home).args([
"--output",
"json",
"node",
"report",
run_id,
"n-0001",
"--from-file",
report_file.to_str().unwrap(),
]));
}
fn latest_run_status(events: &Path) -> Option<String> {
read_events(events)
.into_iter()
.filter(|v| v["kind"] == "run.status")
.filter_map(|v| v["data"]["status"].as_str().map(str::to_string))
.next_back()
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn terminal_report_rolls_run_to_done_and_cleans_up() {
let home = TestHome::new();
let dir = TempDir::new().unwrap();
let run_id = create_run(&home, "spinoff", "rollup-done");
forge_terminal_worker_node(
&home,
&run_id,
"spinoff",
r#"{"success": true, "summary": "ok", "discussion_items": [], "spinoff_proposals": [], "wrap_up_recommendations": []}"#,
);
run_ok(
bin(&home)
.env("TMUX_BIN", fake_tmux_recorder(dir.path()))
.env("GIT_BIN", fake_git_recorder(dir.path()))
.args(["--output", "json", "supervise", &run_id, "--once"]),
);
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(
latest_run_status(&events).as_deref(),
Some("done"),
"supervisor must roll the run up to done"
);
let tmux = log_contents(dir.path(), "tmux.log");
assert!(
tmux.contains("kill-window -t @42"),
"tmux window not closed: {tmux:?}"
);
let git = log_contents(dir.path(), "git.log");
assert!(
git.contains("worktree remove --force /fake/wt"),
"worktree not removed: {git:?}"
);
assert!(
git.contains("branch -d -- wt/test-x"),
"branch not deleted with the safe -d on the non-merge path: {git:?}"
);
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn blocked_report_rolls_run_to_failed_but_preserves_branch() {
let home = TestHome::new();
let dir = TempDir::new().unwrap();
let run_id = create_run(&home, "spinoff", "rollup-blocked");
forge_terminal_worker_node(
&home,
&run_id,
"spinoff",
r#"{"success": false, "summary": "boom", "discussion_items": [{"topic": "blocked"}]}"#,
);
run_ok(
bin(&home)
.env("TMUX_BIN", fake_tmux_recorder(dir.path()))
.env("GIT_BIN", fake_git_recorder(dir.path()))
.args(["--output", "json", "supervise", &run_id, "--once"]),
);
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(latest_run_status(&events).as_deref(), Some("failed"));
assert!(log_contents(dir.path(), "tmux.log").contains("kill-window -t @42"));
let git = log_contents(dir.path(), "git.log");
assert!(
!git.contains("worktree remove"),
"blocked path must not remove the worktree: {git:?}"
);
assert!(
!git.contains("branch -d") && !git.contains("branch -D"),
"blocked path must not delete the branch: {git:?}"
);
let preserved = read_events(&events)
.into_iter()
.any(|v| v["kind"] == "cleanup.branch_preserved" && v["data"]["branch"] == "wt/test-x");
assert!(
preserved,
"expected a cleanup.branch_preserved audit event; events: {:?}",
read_events(&events)
.into_iter()
.filter_map(|v| v["kind"].as_str().map(str::to_string))
.collect::<Vec<_>>()
);
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn terminal_via_cancel_still_cleans_up() {
let home = TestHome::new();
let dir = TempDir::new().unwrap();
let run_id = create_run(&home, "spinoff", "cancel-clean");
let node = home.path().join("cancel-node.json");
std::fs::write(
&node,
r#"{"kind":"spinoff","task":"x","worktree_path":"/fake/wt","branch":"wt/test-x","tmux_session":"octl","tmux_window_id":"@42"}"#,
)
.unwrap();
run_ok(bin(&home).args([
"--output",
"json",
"event",
"create",
&run_id,
"--kind",
"node.created",
"--node-id",
"n-0001",
"--from-file",
node.to_str().unwrap(),
]));
run_ok(bin(&home).args(["--output", "json", "run", "cancel", &run_id]));
run_ok(
bin(&home)
.env("TMUX_BIN", fake_tmux_recorder(dir.path()))
.env("GIT_BIN", fake_git_recorder(dir.path()))
.args(["--output", "json", "supervise", &run_id, "--once"]),
);
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(latest_run_status(&events).as_deref(), Some("cancelled"));
assert!(
log_contents(dir.path(), "tmux.log").contains("kill-window -t @42"),
"cancel path must still close the tmux window"
);
assert!(log_contents(dir.path(), "git.log").contains("worktree remove --force /fake/wt"));
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn interactive_kind_completes_but_skips_cleanup() {
let home = TestHome::new();
let dir = TempDir::new().unwrap();
let run_id = create_run(&home, "code", "interactive-noclean");
forge_terminal_worker_node(
&home,
&run_id,
"code",
r#"{"success": true, "summary": "ok"}"#,
);
run_ok(
bin(&home)
.env("TMUX_BIN", fake_tmux_recorder(dir.path()))
.env("GIT_BIN", fake_git_recorder(dir.path()))
.args(["--output", "json", "supervise", &run_id, "--once"]),
);
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(
latest_run_status(&events).as_deref(),
Some("done"),
"run completion (criterion 1) applies to interactive kinds too"
);
assert_eq!(
log_contents(dir.path(), "tmux.log"),
"",
"interactive kind must not close the tmux window"
);
assert_eq!(
log_contents(dir.path(), "git.log"),
"",
"interactive kind must not touch the worktree"
);
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn interactive_kind_with_explicit_merge_cleans_up() {
let home = TestHome::new();
let dir = TempDir::new().unwrap();
let run_id = create_run(&home, "code", "interactive-merged");
forge_terminal_worker_node(
&home,
&run_id,
"code",
r#"{"success": true, "summary": "merged wt/test-x into main via run merge", "via": "explicit-merge"}"#,
);
run_ok(
bin(&home)
.env("TMUX_BIN", fake_tmux_recorder(dir.path()))
.env("GIT_BIN", fake_git_recorder(dir.path()))
.args(["--output", "json", "supervise", &run_id, "--once"]),
);
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(latest_run_status(&events).as_deref(), Some("done"));
let tmux = log_contents(dir.path(), "tmux.log");
assert!(
tmux.contains("kill-window -t @42"),
"explicit-merge must close the interactive window: {tmux:?}"
);
let git = log_contents(dir.path(), "git.log");
assert!(
git.contains("worktree remove --force /fake/wt"),
"explicit-merge must remove the worktree: {git:?}"
);
assert!(
git.contains("branch -D -- wt/test-x"),
"explicit-merge must delete the branch: {git:?}"
);
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn missing_window_records_event_without_failing_run() {
let home = TestHome::new();
let dir = TempDir::new().unwrap();
let run_id = create_run(&home, "spinoff", "orphan-window");
forge_terminal_worker_node(
&home,
&run_id,
"spinoff",
r#"{"success": true, "summary": "ok"}"#,
);
let tmux = fake_recorder(
dir.path(),
"fake-tmux.sh",
"tmux.log",
"case \"$*\" in *kill-window*) exit 1;; *list-windows*) exit 0;; esac",
);
run_ok(
bin(&home)
.env("TMUX_BIN", &tmux)
.env("GIT_BIN", fake_git_recorder(dir.path()))
.args(["--output", "json", "supervise", &run_id, "--once"]),
);
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(
latest_run_status(&events).as_deref(),
Some("done"),
"an orphaned window must not fail the run"
);
assert_eq!(
count_kind(&events, "cleanup.window_missing"),
1,
"the orphaned window must be recorded once: {:?}",
read_events(&events)
.into_iter()
.map(|v| v["kind"].clone())
.collect::<Vec<_>>()
);
assert!(log_contents(dir.path(), "tmux.log").contains("kill-window -t @42"));
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn v2_agent_pid_discovery_via_liveness_probe() {
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "v2-pid");
let our_pid = std::process::id();
let report = home.path().join("v2-node.json");
std::fs::write(
&report,
format!(
r#"{{"kind":"spinoff","task":"x","agent_pid":{our_pid},"tmux_window":"never-existed"}}"#
),
)
.unwrap();
run_ok(bin(&home).args([
"--output",
"json",
"event",
"create",
&run_id,
"--kind",
"node.created",
"--node-id",
"n-0001",
"--from-file",
report.to_str().unwrap(),
]));
let node_p = run_dir(&home, &run_id).join("nodes").join("n-0001.json");
let mut n: Value = serde_json::from_slice(&std::fs::read(&node_p).unwrap()).unwrap();
n["tmux_window"] = Value::Null;
std::fs::write(&node_p, serde_json::to_vec_pretty(&n).unwrap()).unwrap();
run_ok(bin(&home).env("OCTL_WATCHDOG_GRACE_SECS", "0").args([
"--output",
"json",
"supervise",
&run_id,
"--once",
]));
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(
count_kind(&events, "node.report"),
0,
"alive PID must not synthesize a failed node.report"
);
let mut n: Value = serde_json::from_slice(&std::fs::read(&node_p).unwrap()).unwrap();
n["agent_pid"] = Value::from(0x3FFF_FFFE_i64);
std::fs::write(&node_p, serde_json::to_vec_pretty(&n).unwrap()).unwrap();
run_ok(bin(&home).env("OCTL_WATCHDOG_GRACE_SECS", "0").args([
"--output",
"json",
"supervise",
&run_id,
"--once",
]));
assert!(
count_kind(&events, "node.report") >= 1,
"dead PID must synthesize a failed node.report. events={:?}",
read_events(&events)
.into_iter()
.map(|v| v["kind"].clone())
.collect::<Vec<_>>()
);
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn v3_kill_and_start_time_identity() {
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "v3-st");
let our_pid = std::process::id();
let report = home.path().join("v3.json");
std::fs::write(
&report,
format!(r#"{{"kind":"spinoff","task":"x","agent_pid":{our_pid}}}"#),
)
.unwrap();
run_ok(bin(&home).args([
"--output",
"json",
"event",
"create",
&run_id,
"--kind",
"node.created",
"--node-id",
"n-0001",
"--from-file",
report.to_str().unwrap(),
]));
let node_p = run_dir(&home, &run_id).join("nodes").join("n-0001.json");
let mut n: Value = serde_json::from_slice(&std::fs::read(&node_p).unwrap()).unwrap();
n["agent_pid_start_time"] = Value::String("1970-01-01T00:00:00Z".into());
n["tmux_window"] = Value::Null;
std::fs::write(&node_p, serde_json::to_vec_pretty(&n).unwrap()).unwrap();
run_ok(bin(&home).env("OCTL_WATCHDOG_GRACE_SECS", "0").args([
"--output",
"json",
"supervise",
&run_id,
"--once",
]));
let events = run_dir(&home, &run_id).join("events.jsonl");
let reports = read_events(&events)
.into_iter()
.filter(|v| v["kind"] == "node.report")
.collect::<Vec<_>>();
assert_eq!(reports.len(), 1, "recycled PID must synthesize one report");
assert_eq!(reports[0]["data"]["reason"], "agent-pid-recycled");
}
fn forge_pid_node(home: &TempDir, run_id: &str, agent_pid: i64) -> std::path::PathBuf {
let node = home.path().join(format!("wd-node-{run_id}.json"));
std::fs::write(
&node,
format!(r#"{{"kind":"spinoff","task":"x","agent_pid":{agent_pid}}}"#),
)
.unwrap();
run_ok(bin(home).args([
"--output",
"json",
"event",
"create",
run_id,
"--kind",
"node.created",
"--node-id",
"n-0001",
"--from-file",
node.to_str().unwrap(),
]));
let node_p = run_dir(home, run_id).join("nodes").join("n-0001.json");
let mut n: Value = serde_json::from_slice(&std::fs::read(&node_p).unwrap()).unwrap();
n["tmux_window"] = Value::Null;
std::fs::write(&node_p, serde_json::to_vec_pretty(&n).unwrap()).unwrap();
node_p
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn fresh_spawn_dead_pid_suppressed_within_grace() {
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "fresh-dead-grace");
forge_pid_node(&home, &run_id, 0x3FFF_FFFE_i64);
run_ok(bin(&home).args(["--output", "json", "supervise", &run_id, "--once"]));
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(
count_kind(&events, "node.report"),
0,
"a fresh node within the spawn grace must not be terminalized even \
though its PID reads dead, events={:?}",
read_events(&events)
.into_iter()
.map(|v| v["kind"].clone())
.collect::<Vec<_>>()
);
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn fresh_spawn_alive_pid_no_synthesis() {
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "fresh-alive");
forge_pid_node(&home, &run_id, i64::from(std::process::id()));
run_ok(bin(&home).args(["--output", "json", "supervise", &run_id, "--once"]));
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(
count_kind(&events, "node.report"),
0,
"an alive fresh node must not be terminalized"
);
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn dead_pid_synthesizes_after_grace() {
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "dead-after-grace");
let node_p = forge_pid_node(&home, &run_id, 0x3FFF_FFFE_i64);
let mut n: Value = serde_json::from_slice(&std::fs::read(&node_p).unwrap()).unwrap();
n["started_at"] = Value::String("2020-01-01T00:00:00Z".into());
std::fs::write(&node_p, serde_json::to_vec_pretty(&n).unwrap()).unwrap();
run_ok(bin(&home).args(["--output", "json", "supervise", &run_id, "--once"]));
let events = run_dir(&home, &run_id).join("events.jsonl");
let reports = read_events(&events)
.into_iter()
.filter(|v| v["kind"] == "node.report")
.collect::<Vec<_>>();
assert_eq!(
reports.len(),
1,
"a node past the spawn grace with a dead PID must synthesize one \
terminal report"
);
assert_eq!(reports[0]["data"]["reason"], "agent-died");
}
fn wedge_corrupt_middle_line(events: &Path) {
let original = std::fs::read_to_string(events).unwrap();
let mut trailing: Value = serde_json::from_str(original.lines().next().unwrap()).unwrap();
trailing["seq"] = Value::from(900);
let trailing = serde_json::to_string(&trailing).unwrap();
let mut rewritten = String::new();
rewritten.push_str(original.trim_end_matches('\n'));
rewritten.push('\n');
rewritten.push_str("{this is not valid json at all\n");
rewritten.push_str(&trailing);
rewritten.push('\n');
std::fs::write(events, rewritten).unwrap();
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn corrupt_tail_line_is_quarantined_and_log_heals() {
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "corrupt-tail");
let events = run_dir(&home, &run_id).join("events.jsonl");
wedge_corrupt_middle_line(&events);
run_ok(bin(&home).args(["--output", "json", "supervise", &run_id, "--max-iter", "4"]));
let evs = read_events(&events);
let quarantined: Vec<&Value> = evs
.iter()
.filter(|v| v["kind"] == "supervisor.event_log_quarantined")
.collect();
assert_eq!(
quarantined.len(),
1,
"expected exactly one quarantine event"
);
assert_eq!(
count_kind(&events, "supervisor.event_log_skipped_line"),
0,
"quarantine replaces the in-memory skip diagnostic"
);
assert!(
!std::fs::read_to_string(&events)
.unwrap()
.contains("not valid json"),
"the corrupt line must be excised from the recovered log"
);
let backup = quarantined[0]["data"]["backup_path"].as_str().unwrap();
let backup = Path::new(backup);
assert!(
backup.exists(),
"backup file must exist: {}",
backup.display()
);
assert!(std::fs::read_to_string(backup)
.unwrap()
.contains("not valid json"));
assert!(quarantined[0]["data"]["removed_byte_offsets"]
.as_array()
.is_some_and(|a| !a.is_empty()));
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn corrupt_tail_line_is_skipped_once_without_looping() {
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "corrupt-tail");
let events = run_dir(&home, &run_id).join("events.jsonl");
wedge_corrupt_middle_line(&events);
run_ok(bin(&home).args([
"--output",
"json",
"supervise",
&run_id,
"--max-iter",
"4",
"--no-quarantine-corrupt-lines",
]));
let skipped: Vec<Value> = std::fs::read_to_string(&events)
.unwrap()
.lines()
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
.filter(|v| v["kind"] == "supervisor.event_log_skipped_line")
.collect();
assert_eq!(
skipped.len(),
1,
"expected exactly one skip event, got {}",
skipped.len()
);
assert!(
skipped[0]["data"]["byte_offset"].is_number(),
"skip event carries the byte offset"
);
assert!(
skipped[0]["data"]["line_excerpt"]
.as_str()
.unwrap_or_default()
.contains("not valid json"),
"skip event carries a line excerpt: {:?}",
skipped[0]["data"]
);
assert!(std::fs::read_to_string(&events)
.unwrap()
.contains("not valid json"));
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn lenient_poll_skips_torn_trailing_line() {
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "torn-tail");
let events = run_dir(&home, &run_id).join("events.jsonl");
let mut contents = std::fs::read_to_string(&events).unwrap();
if !contents.ends_with('\n') {
contents.push('\n');
}
contents.push_str(r#"{"kind":"torn.marker","seq":99}"#);
contents.push('\n');
contents.push_str(r#"{"kind":"torn.marker","seq":100"#); std::fs::write(&events, contents).unwrap();
assert_eq!(count_kind_lenient(&events, "torn.marker"), 1);
assert_eq!(wait_for_kind(&events, "torn.marker", 1), 1);
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn v7_deterministic_id_dedup_under_crash() {
let home = TestHome::new();
let parent = create_run(&home, "orchestrated", "v7-parent");
let p_node = home.path().join("v7-parent-node.json");
std::fs::write(&p_node, r#"{"kind":"orchestrated","task":"x"}"#).unwrap();
run_ok(bin(&home).args([
"--output",
"json",
"event",
"create",
&parent,
"--kind",
"node.created",
"--node-id",
"n-0001",
"--from-file",
p_node.to_str().unwrap(),
]));
let child_create = run_ok(bin(&home).args([
"--output",
"json",
"run",
"create",
"--kind",
"spinoff",
"--title",
"v7-child",
"--parent-run-id",
&parent,
"--parent-node-id",
"n-0001",
]));
let child = child_create["data"]["run_id"].as_str().unwrap().to_string();
let c_node = home.path().join("v7-child-node.json");
std::fs::write(&c_node, r#"{"kind":"spinoff","task":"x"}"#).unwrap();
run_ok(bin(&home).args([
"--output",
"json",
"event",
"create",
&child,
"--kind",
"node.created",
"--node-id",
"n-0001",
"--from-file",
c_node.to_str().unwrap(),
]));
let report = home.path().join("v7-report.json");
std::fs::write(
&report,
r#"{
"success": true,
"summary": "v7",
"discussion_items": [
{"topic": "d-a", "severity": "discuss", "options": ["x"]},
{"topic": "d-b", "severity": "discuss", "options": ["y"]}
],
"spinoff_proposals": [
{"proposed_title": "s-a", "proposed_kind": "spinoff", "rationale": "r1"},
{"proposed_title": "s-b", "proposed_kind": "spinoff", "rationale": "r2"},
{"proposed_title": "s-c", "proposed_kind": "spinoff", "rationale": "r3"}
],
"wrap_up_recommendations": []
}"#,
)
.unwrap();
run_ok(bin(&home).args([
"--output",
"json",
"node",
"report",
&child,
"n-0001",
"--from-file",
report.to_str().unwrap(),
]));
let state_p = run_dir(&home, &parent).join("supervisor.state.json");
std::fs::write(
&state_p,
format!(r#"{{"schema_version":1,"spawned_children":{{"{child}":1}}}}"#),
)
.unwrap();
run_ok(bin(&home).args(["--output", "json", "supervise", &parent, "--once"]));
let disc_dir = run_dir(&home, &parent).join("discussions");
let spin_dir = run_dir(&home, &parent).join("spinoffs");
let n_disc = std::fs::read_dir(&disc_dir).map_or(0, std::iter::Iterator::count);
let n_spin = std::fs::read_dir(&spin_dir).map_or(0, std::iter::Iterator::count);
assert_eq!(n_disc, 2, "parent must have 2 discussions");
assert_eq!(n_spin, 3, "parent must have 3 spinoffs");
std::fs::remove_file(&state_p).unwrap();
std::fs::write(
&state_p,
format!(r#"{{"schema_version":1,"spawned_children":{{"{child}":1}}}}"#),
)
.unwrap();
run_ok(bin(&home).args(["--output", "json", "supervise", &parent, "--once"]));
let n_disc2 = std::fs::read_dir(&disc_dir).unwrap().count();
let n_spin2 = std::fs::read_dir(&spin_dir).unwrap().count();
assert_eq!(n_disc2, 2, "replay must not duplicate discussions");
assert_eq!(n_spin2, 3, "replay must not duplicate spinoffs");
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn signal_exit_codes_and_payload() {
use std::io::Read;
for (sig, code, name) in [("TERM", 143, "SIGTERM"), ("INT", 130, "SIGINT")] {
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "sig");
let mut child = bin(&home)
.args(["supervise", &run_id])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn supervisor");
let pid_file = run_dir(&home, &run_id).join("supervisor.pid");
assert!(
poll_until(POLL_DEADLINE, || pid_file.exists()),
"supervisor did not start in time: {}",
pid_file.display()
);
let rc = unsafe { libc::kill(child.id() as i32, sig_num(sig)) };
assert_eq!(
rc,
0,
"{name}: kill({sig}) failed: {}",
std::io::Error::last_os_error()
);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let status = loop {
if let Some(s) = child.try_wait().expect("try_wait") {
break s;
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("{name}: supervisor did not exit within 10s of {sig}");
}
std::thread::sleep(Duration::from_millis(50));
};
assert_eq!(
status.code(),
Some(code),
"{name} must exit {code}, got {status:?}"
);
assert!(
!pid_file.exists(),
"{name}: supervisor.pid must be removed on signal exit"
);
let events = run_dir(&home, &run_id).join("events.jsonl");
let mut s = String::new();
std::fs::File::open(&events)
.unwrap()
.read_to_string(&mut s)
.unwrap();
let exited = s
.lines()
.map(|l| serde_json::from_str::<Value>(l).unwrap())
.find(|v| v["kind"] == "supervisor.exited")
.expect("supervisor.exited present");
assert_eq!(exited["data"]["reason"], "signal");
assert_eq!(exited["data"]["signal"], name);
}
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn sigterm_flushes_buffered_supervisor_logs() {
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "sigterm-flush");
let mut child = bin(&home)
.env("OCTL_TEST_SLOW_LOG_WRITES", "250")
.args(["supervise", &run_id])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn supervisor");
let pid_file = run_dir(&home, &run_id).join("supervisor.pid");
assert!(
poll_until(POLL_DEADLINE, || pid_file.exists()),
"supervisor did not start in time: {}",
pid_file.display()
);
unsafe {
libc::kill(child.id() as i32, libc::SIGTERM);
}
let status = child.wait().expect("wait");
assert_eq!(status.code(), Some(143), "SIGTERM must exit 143");
let log = home.path().join("logs").join("orchestratectl.log.jsonl");
let contents = std::fs::read_to_string(&log).unwrap_or_default();
let saw_shutdown_log = contents
.lines()
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
.any(|v| {
v["target"] == "orchestratectl::supervise"
&& v["fields"]["message"]
.as_str()
.is_some_and(|m| m.contains("received termination signal"))
});
assert!(
saw_shutdown_log,
"supervisor's buffered shutdown log line was not flushed on SIGTERM; \
log contents:\n{contents}"
);
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn signal_during_boot_exits_143() {
use std::io::Read;
for (sig, code, name) in [("TERM", 143, "SIGTERM"), ("INT", 130, "SIGINT")] {
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "sig-boot");
let mut child = bin(&home)
.env("OCTL_TEST_SLOW_BOOT", "5000")
.args(["supervise", &run_id])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn supervisor");
let pid_file = run_dir(&home, &run_id).join("supervisor.pid");
assert!(
poll_until(POLL_DEADLINE, || pid_file.exists()),
"{name}: supervisor did not claim pid file in time: {}",
pid_file.display()
);
let rc = unsafe { libc::kill(child.id() as i32, sig_num(sig)) };
assert_eq!(
rc,
0,
"{name}: kill({sig}) failed: {}",
std::io::Error::last_os_error()
);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let status = loop {
if let Some(s) = child.try_wait().expect("try_wait") {
break s;
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("{name}: supervisor did not exit within 10s of a boot-window {sig}");
}
std::thread::sleep(Duration::from_millis(50));
};
assert_eq!(
status.code(),
Some(code),
"boot-window {name} must exit {code}, got {status:?}"
);
assert!(
!pid_file.exists(),
"{name}: supervisor.pid must be removed on a boot-window signal exit"
);
let events = run_dir(&home, &run_id).join("events.jsonl");
let mut s = String::new();
std::fs::File::open(&events)
.unwrap()
.read_to_string(&mut s)
.unwrap();
let exited = s
.lines()
.map(|l| serde_json::from_str::<Value>(l).unwrap())
.find(|v| v["kind"] == "supervisor.exited")
.expect("supervisor.exited present even on a boot-window signal");
assert_eq!(exited["data"]["reason"], "signal");
assert_eq!(exited["data"]["signal"], name);
}
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn watchdog_defers_when_report_already_present() {
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "wd-defer");
let our_pid = std::process::id();
let report = home.path().join("wd-node.json");
std::fs::write(
&report,
format!(r#"{{"kind":"spinoff","task":"x","agent_pid":{our_pid}}}"#),
)
.unwrap();
run_ok(bin(&home).args([
"--output",
"json",
"event",
"create",
&run_id,
"--kind",
"node.created",
"--node-id",
"n-0001",
"--from-file",
report.to_str().unwrap(),
]));
let node_p = run_dir(&home, &run_id).join("nodes").join("n-0001.json");
let mut n: Value = serde_json::from_slice(&std::fs::read(&node_p).unwrap()).unwrap();
n["agent_pid"] = Value::from(0x3FFF_FFFE_i64); n["tmux_window"] = Value::Null;
n["last_report"] = serde_json::json!({"success": true, "summary": "real report"});
std::fs::write(&node_p, serde_json::to_vec_pretty(&n).unwrap()).unwrap();
run_ok(bin(&home).env("OCTL_WATCHDOG_GRACE_SECS", "0").args([
"--output",
"json",
"supervise",
&run_id,
"--once",
]));
let events = run_dir(&home, &run_id).join("events.jsonl");
assert_eq!(
count_kind(&events, "node.report"),
0,
"watchdog must defer to the present last_report and synthesize nothing, events={:?}",
read_events(&events)
.into_iter()
.map(|v| v["kind"].clone())
.collect::<Vec<_>>()
);
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn spawned_supervisor_survives_sighup_to_spawner_group() {
use std::os::unix::process::CommandExt;
use std::process::Command;
use std::time::Instant;
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "sighup-survive");
let _agent = forge_live_worker_node(&home, &run_id);
let bin_path = env!("CARGO_BIN_EXE_orchestratectl");
let script =
format!("{bin_path} --output json run reattach {run_id} >/dev/null 2>&1; sleep 30");
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(script);
cmd.env("ORCHESTRATECTL_HOME", home.path());
cmd.env("OCTL_TEST_SKIP_MATERIALIZE", "1");
cmd.env("TMUX_BIN", "/usr/bin/true");
cmd.env("OCTL_WATCHDOG_GRACE_SECS", "60");
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::null());
unsafe {
cmd.pre_exec(|| {
if libc::setpgid(0, 0) != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
let mut spawner = cmd.spawn().expect("spawn shell");
let spawner_pgid = spawner.id() as i32;
let deadline = Instant::now() + Duration::from_secs(30);
let pid_file = run_dir(&home, &run_id).join("supervisor.pid");
let sup_pid = loop {
if let Some(p) = read_first_token_pid(&pid_file) {
if pid_alive(p) {
break p;
}
}
if Instant::now() >= deadline {
let _ = kill_group(spawner_pgid);
let _ = spawner.wait();
panic!("supervisor did not write a live pid file in time");
}
std::thread::sleep(Duration::from_millis(50));
};
unsafe {
libc::kill(-spawner_pgid, libc::SIGHUP);
}
let _ = spawner.wait();
assert!(
!poll_until(Duration::from_secs(3), || !pid_alive(sup_pid)),
"supervisor (pid {sup_pid}) must survive SIGHUP to the spawner's group"
);
assert!(
pid_alive(sup_pid),
"supervisor (pid {sup_pid}) must still be alive after spawner-group SIGHUP"
);
unsafe {
libc::kill(sup_pid as i32, libc::SIGTERM);
}
poll_until(Duration::from_secs(5), || !pid_alive(sup_pid));
}
fn read_first_token_pid(path: &Path) -> Option<u32> {
let s = std::fs::read_to_string(path).ok()?;
s.split_whitespace().next()?.parse::<u32>().ok()
}
fn pid_alive(pid: u32) -> bool {
if pid == 0 {
return false;
}
let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
fn kill_group(pgid: i32) -> std::io::Result<()> {
let rc = unsafe { libc::kill(-pgid, libc::SIGTERM) };
if rc == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
fn sig_num(sig: &str) -> libc::c_int {
match sig {
"TERM" => libc::SIGTERM,
"INT" => libc::SIGINT,
_ => unreachable!(),
}
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn v8_reattach_end_to_end() {
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "v8");
run_ok(bin(&home).args(["--output", "json", "run", "reattach", &run_id, "--once"]));
let events = run_dir(&home, &run_id).join("events.jsonl");
assert!(wait_for_kind(&events, "supervisor.exited", 1) >= 1);
assert!(count_kind(&events, "supervisor.reattach-requested") >= 1);
assert!(count_kind(&events, "supervisor.reattached") >= 1);
let pid_file = run_dir(&home, &run_id).join("supervisor.pid");
assert!(
poll_until(POLL_DEADLINE, || !pid_file.exists()),
"prior --once supervisor did not remove its pid file in time: {}",
pid_file.display()
);
run_ok(bin(&home).args(["--output", "json", "run", "reattach", &run_id, "--once"]));
assert!(wait_for_kind(&events, "supervisor.reattach-requested", 2) >= 2);
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn v9_cancel_synthesizes_report_no_spinoffs() {
let home = TestHome::new();
let parent = create_run(&home, "orchestrated", "v9-parent");
let p_node = home.path().join("v9-pn.json");
std::fs::write(&p_node, r#"{"kind":"orchestrated","task":"x"}"#).unwrap();
run_ok(bin(&home).args([
"--output",
"json",
"event",
"create",
&parent,
"--kind",
"node.created",
"--node-id",
"n-0001",
"--from-file",
p_node.to_str().unwrap(),
]));
let child = run_ok(bin(&home).args([
"--output",
"json",
"run",
"create",
"--kind",
"spinoff",
"--title",
"v9-c",
"--parent-run-id",
&parent,
"--parent-node-id",
"n-0001",
]))["data"]["run_id"]
.as_str()
.unwrap()
.to_string();
let c_node = home.path().join("v9-cn.json");
std::fs::write(&c_node, r#"{"kind":"spinoff","task":"x"}"#).unwrap();
run_ok(bin(&home).args([
"--output",
"json",
"event",
"create",
&child,
"--kind",
"node.created",
"--node-id",
"n-0001",
"--from-file",
c_node.to_str().unwrap(),
]));
run_ok(bin(&home).args(["--output", "json", "run", "cancel", &child]));
std::fs::write(
run_dir(&home, &parent).join("supervisor.state.json"),
format!(r#"{{"schema_version":1,"spawned_children":{{"{child}":1}}}}"#),
)
.unwrap();
run_ok(bin(&home).args(["--output", "json", "supervise", &parent, "--once"]));
let n_spin = std::fs::read_dir(run_dir(&home, &parent).join("spinoffs"))
.map_or(0, std::iter::Iterator::count);
let n_disc = std::fs::read_dir(run_dir(&home, &parent).join("discussions"))
.map_or(0, std::iter::Iterator::count);
assert_eq!(n_spin, 0, "cancelled child must not propagate spinoffs");
assert_eq!(n_disc, 0, "cancelled child must not propagate discussions");
let parent_node: Value = serde_json::from_slice(
&std::fs::read(run_dir(&home, &parent).join("nodes").join("n-0001.json")).unwrap(),
)
.unwrap();
assert!(
parent_node["last_processed_report_seq_by_child"]
.get(&child)
.is_some(),
"cursor not advanced: {parent_node:?}"
);
let parent_events =
std::fs::read_to_string(run_dir(&home, &parent).join("events.jsonl")).unwrap();
let cursor_evs: Vec<Value> = parent_events
.lines()
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
.filter(|e| e["kind"] == "supervisor.cursor_advanced")
.collect();
assert_eq!(
cursor_evs.len(),
1,
"exactly one cursor_advanced event must back the projection: {parent_events}"
);
assert_eq!(
cursor_evs[0]["data"]["child_run_id"],
serde_json::json!(child)
);
assert_eq!(cursor_evs[0]["node_id"], serde_json::json!("n-0001"));
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn self_terminate_when_run_dir_vanishes() {
use std::time::Instant;
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "self-term");
let rdir = run_dir(&home, &run_id);
let mut child = bin(&home)
.args(["supervise", &run_id])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn supervisor");
let pid_file = rdir.join("supervisor.pid");
let deadline = Instant::now() + Duration::from_secs(30);
while !pid_file.exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
assert!(pid_file.exists(), "supervisor did not start in time");
std::fs::remove_file(rdir.join("manifest.json")).expect("remove manifest");
let deadline = Instant::now() + Duration::from_secs(10);
let status = loop {
if let Some(s) = child.try_wait().expect("try_wait") {
break s;
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("supervisor did not self-terminate within 10s of run dir removal");
}
std::thread::sleep(Duration::from_millis(50));
};
assert_eq!(
status.code(),
Some(0),
"self-terminate must be a clean exit 0, got {status:?}"
);
let events = rdir.join("events.jsonl");
assert!(
count_kind(&events, "supervisor.self-terminated") >= 1,
"expected a supervisor.self-terminated event, got {:?}",
read_events(&events)
.into_iter()
.map(|v| v["kind"].clone())
.collect::<Vec<_>>()
);
assert!(
!pid_file.exists(),
"supervisor.pid must be removed on self-terminate"
);
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn self_terminate_when_whole_run_dir_removed() {
use std::time::Instant;
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "self-term-dir");
let rdir = run_dir(&home, &run_id);
let mut child = bin(&home)
.args(["supervise", &run_id])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn supervisor");
let pid_file = rdir.join("supervisor.pid");
let deadline = Instant::now() + Duration::from_secs(30);
while !pid_file.exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
assert!(pid_file.exists(), "supervisor did not start in time");
let del_deadline = Instant::now() + Duration::from_secs(5);
loop {
match std::fs::remove_dir_all(&rdir) {
Ok(()) => break,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => break,
Err(_) if Instant::now() < del_deadline => {
std::thread::sleep(Duration::from_millis(20));
}
Err(e) => panic!("remove run dir: {e}"),
}
}
let deadline = Instant::now() + Duration::from_secs(10);
let status = loop {
if let Some(s) = child.try_wait().expect("try_wait") {
break s;
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("supervisor did not self-terminate within 10s of run dir removal");
}
std::thread::sleep(Duration::from_millis(50));
};
assert_eq!(
status.code(),
Some(0),
"self-terminate must be a clean exit 0, got {status:?}"
);
assert!(
!rdir.exists(),
"supervisor must not resurrect the deleted run dir: {}",
std::fs::read_dir(&rdir)
.map(|d| d
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(", "))
.unwrap_or_default()
);
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn no_worker_node_run_terminalizes_failed() {
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "no-worker");
let rdir = run_dir(&home, &run_id);
let events = rdir.join("events.jsonl");
let manifest: Value =
serde_json::from_slice(&std::fs::read(rdir.join("manifest.json")).unwrap()).unwrap();
assert_eq!(manifest["node_count"], 0, "precondition: no worker node");
run_ok(bin(&home).env("OCTL_NO_WORKER_GRACE_SECS", "0").args([
"--output",
"json",
"supervise",
&run_id,
"--max-iter",
"12",
]));
let manifest: Value =
serde_json::from_slice(&std::fs::read(rdir.join("manifest.json")).unwrap()).unwrap();
assert_eq!(
manifest["status"], "failed",
"no-worker run must terminalize failed, not stay pending"
);
assert_eq!(latest_run_status(&events).as_deref(), Some("failed"));
let events_v = read_events(&events);
let failed_reason = events_v
.iter()
.filter(|v| v["kind"] == "run.status" && v["data"]["status"] == "failed")
.find_map(|v| v["data"]["reason"].as_str());
assert_eq!(failed_reason, Some("no-worker-node"));
let exit_reason = events_v
.iter()
.filter(|v| v["kind"] == "supervisor.exited")
.find_map(|v| v["data"]["reason"].as_str());
assert_eq!(
exit_reason,
Some("supervisor-spawn-failed"),
"must not exit work-complete with zero children"
);
}
#[test]
#[file_serial(key, path => "/tmp/octl-test-supervise.lock")]
fn no_worker_guard_defers_within_create_window() {
let home = TestHome::new();
let run_id = create_run(&home, "spinoff", "young-no-worker");
let rdir = run_dir(&home, &run_id);
run_ok(bin(&home).args(["--output", "json", "supervise", &run_id, "--max-iter", "8"]));
let manifest: Value =
serde_json::from_slice(&std::fs::read(rdir.join("manifest.json")).unwrap()).unwrap();
assert_eq!(
manifest["status"], "pending",
"a young zero-node run is still materializing; the guard must not fail it"
);
let events_v = read_events(&rdir.join("events.jsonl"));
assert!(
!events_v
.iter()
.any(|v| v["kind"] == "run.status" && v["data"]["status"] == "failed"),
"no premature no-worker terminalization inside the create window"
);
}