use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::process::Command;
use serde_json::Value;
use tempfile::TempDir;
mod common;
use common::TestHome;
const KINDS: &[&str] = &[
"code",
"spinoff",
"orchestrated",
"research",
"technical-decision",
"make-skill",
"fan-out",
"bugfix",
];
fn write_fake_create_sh(dir: &TempDir, stdout: &str, exit_code: i32) -> PathBuf {
let path = dir.path().join("fake-create.sh");
let body = format!("#!/bin/bash\ncat <<'EOF'\n{stdout}\nEOF\nexit {exit_code}\n");
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();
path
}
fn fake_success_stdout(kind: &str, pid: u32) -> String {
format!(
r#"{{"schema_version":1,"type":"{kind}","branch":"wt/test-{kind}","worktree_path":"/tmp/wt-{kind}","tmux_window":"🚀 wt/test-{kind}","agent_pid_hint":{pid},"workmux_session":"test"}}"#
)
}
fn bin(home: &TempDir, script: &std::path::Path) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_orchestratectl"));
c.env("ORCHESTRATECTL_HOME", home.path());
c.env("OCTL_CREATE_SH", script);
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 JSON")
}
fn run_fail(cmd: &mut Command) -> (i32, Value) {
let out = cmd.output().expect("spawn");
assert!(!out.status.success(), "expected failure");
let code = out.status.code().expect("exit code");
let stderr = String::from_utf8(out.stderr).expect("utf8");
let last = stderr.lines().last().expect("stderr line");
let v: Value = serde_json::from_str(last).expect("error envelope JSON");
(code, v)
}
#[test]
fn each_kind_spawns_and_emits_node_created() {
for kind in KINDS {
let home = TestHome::new();
let pid = std::process::id();
let script = write_fake_create_sh(&home, &fake_success_stdout(kind, pid), 0);
let v = run_ok(bin(&home, &script).args([
"--output", "json", "run", "create", "--kind", kind, "--title", "smoke", "--task",
"do work",
]));
let data = &v["data"];
assert_eq!(data["kind"], *kind, "kind in payload for {kind}: {data}");
assert_eq!(data["node_id"], "n-0001", "node_id for {kind}");
assert_eq!(data["branch"], format!("wt/test-{kind}"));
assert_eq!(data["worktree_path"], format!("/tmp/wt-{kind}"));
assert!(
data["supervisor"].as_u64().is_some(),
"supervisor pid for {kind}: {data}"
);
let run_id = data["run_id"].as_str().unwrap();
let events =
std::fs::read_to_string(home.path().join("runs").join(run_id).join("events.jsonl"))
.unwrap();
let saw = events.lines().any(|l| {
let v: Value = serde_json::from_str(l).unwrap();
v["kind"] == "node.created" && v["data"]["agent_pid"].as_u64() == Some(u64::from(pid))
});
assert!(
saw,
"node.created with agent_pid missing for {kind}: {events}"
);
}
}
#[test]
fn missing_task_and_prompt_file_is_user_error() {
let home = TempDir::new().unwrap();
let script = write_fake_create_sh(&home, "", 0);
let (code, v) = run_fail(bin(&home, &script).args([
"--output", "json", "run", "create", "--kind", "spinoff", "--title", "x",
]));
assert_eq!(code, 1);
assert_eq!(v["error"]["code"], "missing-task-or-prompt-file");
}
#[test]
fn create_sh_exit_2_propagates_as_system_error() {
let home = TempDir::new().unwrap();
let path = home.path().join("fake-create.sh");
let body = "#!/bin/bash\necho '{\"schema_version\":1,\"error\":{\"code\":\"workmux-missing\",\"message\":\"workmux not installed\"}}' >&2\nexit 2\n";
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();
let (code, v) = run_fail(bin(&home, &path).args([
"--output", "json", "run", "create", "--kind", "spinoff", "--title", "x", "--task", "do",
]));
assert_eq!(
code, 2,
"create.sh exit 2 should map to orchestratectl exit 2"
);
assert!(
v["error"]["code"]
.as_str()
.unwrap()
.starts_with("create_sh_error_"),
"expected create_sh_error_ prefix: {v}"
);
}
fn write_argv_recording_create_sh(
dir: &TempDir,
argv_path: &std::path::Path,
stdout: &str,
) -> PathBuf {
let path = dir.path().join("argv-create.sh");
let body = format!(
"#!/bin/bash\nprintf '%s\\n' \"$@\" > '{}'\ncat <<'EOF'\n{stdout}\nEOF\nexit 0\n",
argv_path.display()
);
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();
path
}
#[test]
fn headless_forwards_parent_session_to_create_sh() {
let home = TestHome::new();
let argv = home.path().join("create-argv.txt");
let script = write_argv_recording_create_sh(
&home,
&argv,
&fake_success_stdout("spinoff", std::process::id()),
);
run_ok(bin(&home, &script).args([
"--output",
"json",
"run",
"create",
"--kind",
"spinoff",
"--title",
"hl",
"--task",
"do work",
"--headless",
]));
let recorded = std::fs::read_to_string(&argv).expect("create.sh recorded its argv");
let forwarded: Vec<&str> = recorded.lines().collect();
let pos = forwarded
.iter()
.position(|a| *a == "--parent-session")
.unwrap_or_else(|| panic!("--parent-session not forwarded; argv={forwarded:?}"));
assert_eq!(
forwarded.get(pos + 1).copied(),
Some("headless"),
"--parent-session value should be the default headless session; argv={forwarded:?}"
);
}
#[test]
fn foreground_omits_parent_session_flag() {
let home = TestHome::new();
let argv = home.path().join("create-argv.txt");
let script = write_argv_recording_create_sh(
&home,
&argv,
&fake_success_stdout("spinoff", std::process::id()),
);
run_ok(bin(&home, &script).args([
"--output", "json", "run", "create", "--kind", "spinoff", "--title", "fg", "--task",
"do work",
]));
let recorded = std::fs::read_to_string(&argv).expect("create.sh recorded its argv");
assert!(
!recorded.lines().any(|a| a == "--parent-session"),
"foreground spawn must not forward --parent-session; argv={recorded:?}"
);
}
#[test]
fn source_branch_forwards_base_flag_to_create_sh() {
let home = TestHome::new();
let argv = home.path().join("create-argv.txt");
let script = write_argv_recording_create_sh(
&home,
&argv,
&fake_success_stdout("spinoff", std::process::id()),
);
run_ok(bin(&home, &script).args([
"--output",
"json",
"run",
"create",
"--kind",
"spinoff",
"--title",
"sb",
"--task",
"do work",
"--source-branch",
"orchestrate/integration",
]));
let recorded = std::fs::read_to_string(&argv).expect("create.sh recorded its argv");
let forwarded: Vec<&str> = recorded.lines().collect();
let pos = forwarded
.iter()
.position(|a| *a == "--base")
.unwrap_or_else(|| panic!("--base not forwarded; argv={forwarded:?}"));
assert_eq!(
forwarded.get(pos + 1).copied(),
Some("orchestrate/integration"),
"--base value should be the source branch; argv={forwarded:?}"
);
}
#[test]
fn no_source_branch_omits_base_flag() {
let home = TestHome::new();
let argv = home.path().join("create-argv.txt");
let script = write_argv_recording_create_sh(
&home,
&argv,
&fake_success_stdout("spinoff", std::process::id()),
);
run_ok(bin(&home, &script).args([
"--output", "json", "run", "create", "--kind", "spinoff", "--title", "nosb", "--task",
"do work",
]));
let recorded = std::fs::read_to_string(&argv).expect("create.sh recorded its argv");
assert!(
!recorded.lines().any(|a| a == "--base"),
"run without --source-branch must not forward --base; argv={recorded:?}"
);
}
#[test]
fn task_writes_prompt_file_in_run_dir() {
let home = TestHome::new();
let script = write_fake_create_sh(
&home,
&fake_success_stdout("spinoff", std::process::id()),
0,
);
let v = run_ok(bin(&home, &script).args([
"--output",
"json",
"run",
"create",
"--kind",
"spinoff",
"--title",
"p",
"--task",
"investigate the bug",
]));
let run_id = v["data"]["run_id"].as_str().unwrap();
let prompt =
std::fs::read_to_string(home.path().join("runs").join(run_id).join("prompt.md")).unwrap();
assert_eq!(prompt, "investigate the bug");
}
fn spawn_parent_orchestrate(home: &TempDir, script: &std::path::Path) -> String {
let v = run_ok(bin(home, script).args([
"--output",
"json",
"run",
"create",
"--kind",
"orchestrate",
"--title",
"driver",
"--task",
"drive the dag",
]));
v["data"]["run_id"].as_str().unwrap().to_string()
}
fn count_child_spawned(home: &TempDir, run_id: &str) -> usize {
let path = home.path().join("runs").join(run_id).join("events.jsonl");
let Ok(events) = std::fs::read_to_string(path) else {
return 0;
};
events
.lines()
.filter(|l| serde_json::from_str::<Value>(l).is_ok_and(|v| v["kind"] == "child.spawned"))
.count()
}
#[test]
fn failed_child_spawn_leaves_no_phantom_child() {
let home = TestHome::new();
let ok_script = write_fake_create_sh(&home, &fake_success_stdout("orchestrate", 0), 0);
let parent = spawn_parent_orchestrate(&home, &ok_script);
let fail_path = home.path().join("fail-create.sh");
std::fs::write(
&fail_path,
"#!/bin/bash\necho '{\"schema_version\":1,\"error\":{\"code\":\"workmux-add-failed\",\"message\":\"boom\"}}' >&2\nexit 2\n",
)
.unwrap();
let mut perms = std::fs::metadata(&fail_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&fail_path, perms).unwrap();
let runs_dir = home.path().join("runs");
let before: std::collections::BTreeSet<_> = std::fs::read_dir(&runs_dir)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
let (code, v) = run_fail(bin(&home, &fail_path).args([
"--output",
"json",
"run",
"create",
"--kind",
"orchestrated",
"--title",
"doomed child",
"--task",
"do work",
"--parent-run-id",
&parent,
"--parent-node-id",
"n-0001",
]));
assert_eq!(code, 2, "create.sh exit 2 should surface as exit 2: {v}");
assert!(
v["error"]["code"]
.as_str()
.unwrap()
.starts_with("create_sh_error_"),
"expected create_sh_error_ prefix: {v}"
);
assert_eq!(
count_child_spawned(&home, &parent),
0,
"failed spawn must not emit child.spawned on the parent"
);
let after: std::collections::BTreeSet<_> = std::fs::read_dir(&runs_dir)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(
before, after,
"failed child spawn must leave no new run dir behind"
);
}
#[test]
fn successful_child_spawn_emits_child_spawned() {
let home = TestHome::new();
let script = write_fake_create_sh(
&home,
&fake_success_stdout("orchestrated", std::process::id()),
0,
);
let parent = spawn_parent_orchestrate(&home, &script);
let v = run_ok(bin(&home, &script).args([
"--output",
"json",
"run",
"create",
"--kind",
"orchestrated",
"--title",
"live child",
"--task",
"do work",
"--parent-run-id",
&parent,
"--parent-node-id",
"n-0001",
]));
let child_run_id = v["data"]["run_id"].as_str().unwrap();
assert_eq!(v["data"]["node_id"], "n-0001");
assert_eq!(v["data"]["parent_run_id"], parent);
assert_eq!(v["data"]["lifecycle"], "autonomous");
let parent_events =
std::fs::read_to_string(home.path().join("runs").join(&parent).join("events.jsonl"))
.unwrap();
let spawned: Vec<Value> = parent_events
.lines()
.map(|l| serde_json::from_str::<Value>(l).unwrap())
.filter(|v| v["kind"] == "child.spawned")
.collect();
assert_eq!(
spawned.len(),
1,
"expected one child.spawned: {parent_events}"
);
assert_eq!(spawned[0]["data"]["child_run_id"], child_run_id);
assert_eq!(spawned[0]["data"]["child_kind"], "orchestrated");
assert_eq!(spawned[0]["node_id"], "n-0001");
let child_events = std::fs::read_to_string(
home.path()
.join("runs")
.join(child_run_id)
.join("events.jsonl"),
)
.unwrap();
assert!(
child_events
.lines()
.any(|l| serde_json::from_str::<Value>(l).is_ok_and(|v| v["kind"] == "node.created")),
"child run must have node.created: {child_events}"
);
}