use std::path::{Path, PathBuf};
use std::process::Command;
use octl_core::schema::TmuxIdentity;
use serde::Deserialize;
use serde_json::Value;
use crate::error::{CliError, ExitKind};
#[derive(Debug, Clone, Deserialize)]
pub struct SpawnOutcome {
#[serde(default)]
#[allow(dead_code)]
pub schema_version: u32,
#[serde(default)]
#[allow(dead_code)]
pub r#type: String,
pub branch: String,
pub worktree_path: String,
pub tmux_window: String,
pub agent_pid_hint: i64,
#[serde(default)]
#[allow(dead_code)]
pub workmux_session: Option<String>,
#[serde(default)]
pub tmux_socket: Option<String>,
#[serde(default)]
pub tmux_session: Option<String>,
#[serde(default)]
pub tmux_window_id: Option<String>,
#[serde(default)]
pub tmux_pane_id: Option<String>,
}
impl SpawnOutcome {
pub fn tmux_identity(&self) -> Option<TmuxIdentity> {
let nonempty = |v: &Option<String>| {
v.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
let session = nonempty(&self.tmux_session)?;
let window_id = nonempty(&self.tmux_window_id)?;
Some(TmuxIdentity {
socket: nonempty(&self.tmux_socket),
session,
window_id,
pane_id: nonempty(&self.tmux_pane_id),
})
}
}
pub struct SpawnRequest<'a> {
pub kind: &'a str,
pub branch: &'a str,
pub prompt_file: &'a Path,
pub layout: Option<&'a str>,
pub no_hooks: bool,
pub keep_tmux_on_error: bool,
pub parent_session: Option<&'a str>,
pub agent_startup_timeout: u32,
pub source_branch: Option<&'a str>,
pub cwd: Option<&'a Path>,
}
pub fn create_sh_path() -> PathBuf {
if let Ok(v) = std::env::var("OCTL_CREATE_SH") {
return PathBuf::from(v);
}
let home = std::env::var("HOME").unwrap_or_else(|_| "/".into());
PathBuf::from(home).join(".claude/skills/worktree/scripts/create.sh")
}
pub fn write_prompt_file(run_dir: &Path, task: &str) -> Result<PathBuf, CliError> {
let path = run_dir.join("prompt.md");
std::fs::create_dir_all(run_dir)
.map_err(|e| CliError::system("io_error", format!("mkdir {}: {}", run_dir.display(), e)))?;
std::fs::write(&path, task)
.map_err(|e| CliError::system("io_error", format!("write {}: {}", path.display(), e)))?;
Ok(path)
}
pub fn run_create_sh(req: &SpawnRequest<'_>) -> Result<SpawnOutcome, CliError> {
let script = create_sh_path();
if !script.exists() {
return Err(CliError::system(
"create_sh_missing",
format!(
"create.sh not found at {}; install the worktree skill or set OCTL_CREATE_SH",
script.display()
),
));
}
let mut cmd = Command::new(&script);
if let Some(cwd) = req.cwd {
cmd.current_dir(cwd);
}
cmd.arg("--type").arg(req.kind);
if let Some(layout) = req.layout {
cmd.arg("--layout").arg(layout);
}
if req.no_hooks {
cmd.arg("--no-hooks");
}
if req.keep_tmux_on_error {
cmd.arg("--keep-tmux-on-error");
}
if let Some(session) = req.parent_session {
cmd.arg("--parent-session").arg(session);
}
cmd.arg("--agent-startup-timeout")
.arg(req.agent_startup_timeout.to_string());
if let Some(base) = req.source_branch {
cmd.arg("--base").arg(base);
}
cmd.arg(req.branch).arg(req.prompt_file);
let output = cmd.output().map_err(|e| {
CliError::system(
"spawn_failed",
format!("invoke create.sh ({}): {}", script.display(), e),
)
})?;
if !output.status.success() {
#[allow(clippy::match_same_arms)]
let exit_kind = match output.status.code() {
Some(2) => ExitKind::System,
Some(1) => ExitKind::User,
_ => ExitKind::System,
};
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let (code, message, invalid_value, expected) = parse_error_envelope(&stderr)
.unwrap_or_else(|| {
(
"create_sh_unparseable".to_string(),
format!(
"create.sh exited {} with non-envelope stderr: {}",
output.status.code().unwrap_or(-1),
stderr.trim()
),
None,
None,
)
});
return Err(CliError {
kind: exit_kind,
code: format!("create_sh_error_{code}"),
message,
invalid_value,
expected,
});
}
let stdout = String::from_utf8(output.stdout).map_err(|e| {
CliError::system(
"create_sh_invalid_stdout",
format!("create.sh stdout was not UTF-8: {e}"),
)
})?;
let trimmed = stdout.trim();
let outcome = serde_json::from_str::<SpawnOutcome>(trimmed).map_err(|e| {
CliError::system(
"create_sh_unparseable_stdout",
format!("could not parse create.sh stdout as SpawnOutcome ({e}): {trimmed}"),
)
})?;
if outcome.tmux_identity().is_none() {
tracing::warn!(
tmux_window = %outcome.tmux_window,
branch = %outcome.branch,
"create.sh did not emit a usable qualified tmux identity \
(tmux_session + tmux_window_id, non-empty); falling back to bare \
window-name liveness matching — update the worktree skill's create.sh"
);
}
Ok(outcome)
}
const TMUX_WINDOW_NOT_FOUND_CODE: &str = "create_sh_error_tmux-window-not-found";
const TMUX_MAX_RETRIES: u32 = 3;
const TMUX_RETRY_BACKOFF: std::time::Duration = std::time::Duration::from_millis(1500);
pub fn run_create_sh_with_tmux_retry(req: &SpawnRequest<'_>) -> Result<SpawnOutcome, CliError> {
let backoff = match std::env::var("OCTL_TMUX_RETRY_BACKOFF_MS") {
Ok(v) => match v.trim().parse::<u64>() {
Ok(ms) => std::time::Duration::from_millis(ms),
Err(_) => TMUX_RETRY_BACKOFF,
},
Err(_) => TMUX_RETRY_BACKOFF,
};
let mut attempt: u32 = 0;
loop {
match run_create_sh(req) {
Ok(o) => return Ok(o),
Err(e) if e.code == TMUX_WINDOW_NOT_FOUND_CODE && attempt < TMUX_MAX_RETRIES => {
attempt += 1;
tracing::warn!(
target: "orchestratectl::run",
branch = %req.branch,
attempt,
max = TMUX_MAX_RETRIES,
"create.sh hit the transient tmux-window-not-found race \
(window created then not found); rolled back cleanly, retrying"
);
if !backoff.is_zero() {
std::thread::sleep(backoff);
}
}
Err(e) => return Err(e),
}
}
}
fn parse_error_envelope(stderr: &str) -> Option<(String, String, Option<String>, Option<Value>)> {
for line in stderr.lines().rev() {
let line = line.trim();
if !line.starts_with('{') {
continue;
}
if let Ok(v) = serde_json::from_str::<Value>(line) {
let err = v.get("error")?;
let code = err.get("code").and_then(Value::as_str)?.to_string();
let message = err
.get("message")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let invalid_value = err
.get("invalid_value")
.and_then(Value::as_str)
.map(str::to_string);
let expected = err.get("expected").cloned();
return Some((code, message, invalid_value, expected));
}
}
None
}
pub fn verify_agent_pid(pid: i64) -> Result<(), CliError> {
if pid <= 0 {
return Err(CliError::system(
"agent_pid_invalid",
format!("create.sh returned non-positive agent_pid_hint: {pid}"),
));
}
let pid = u32::try_from(pid).map_err(|_| {
CliError::system(
"agent_pid_invalid",
format!("create.sh returned out-of-range agent_pid_hint: {pid}"),
)
})?;
if !crate::supervise::pid_file::pid_alive(pid) {
return Err(CliError::system(
"agent_pid_discovery_failed",
format!("agent_pid {pid} was not alive after create.sh returned"),
));
}
Ok(())
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::sync::Mutex;
use tempfile::TempDir;
pub(crate) static ENV_LOCK: Mutex<()> = Mutex::new(());
fn fixture_script(dir: &Path, body: &str) -> PathBuf {
let p = dir.join("fake-create.sh");
std::fs::write(&p, body).unwrap();
let mut perms = std::fs::metadata(&p).unwrap().permissions();
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o755);
std::fs::set_permissions(&p, perms).unwrap();
p
}
#[test]
fn parses_success_stdout() {
let _g = ENV_LOCK.lock().unwrap();
let dir = TempDir::new().unwrap();
let me = std::process::id();
let script = fixture_script(
dir.path(),
&format!(
r#"#!/bin/bash
cat <<EOF
{{"schema_version":1,"type":"spinoff","branch":"wt/x","worktree_path":"/tmp/x","tmux_window":"🚀 wt/x","agent_pid_hint":{me},"workmux_session":"orchestratectl"}}
EOF
"#
),
);
std::env::set_var("OCTL_CREATE_SH", &script);
let prompt = dir.path().join("p.md");
std::fs::write(&prompt, "do thing").unwrap();
let out = run_create_sh(&SpawnRequest {
kind: "spinoff",
branch: "wt/x",
prompt_file: &prompt,
layout: None,
no_hooks: false,
keep_tmux_on_error: false,
agent_startup_timeout: 90,
parent_session: None,
source_branch: None,
cwd: None,
})
.unwrap();
assert_eq!(out.branch, "wt/x");
assert_eq!(out.tmux_window, "🚀 wt/x");
assert_eq!(out.agent_pid_hint, i64::from(me));
std::env::remove_var("OCTL_CREATE_SH");
}
#[test]
fn propagates_error_envelope_from_stderr() {
let _g = ENV_LOCK.lock().unwrap();
let dir = TempDir::new().unwrap();
let script = fixture_script(
dir.path(),
r#"#!/bin/bash
echo "some human progress" >&2
echo '{"schema_version":1,"error":{"code":"branch-exists","message":"branch already exists","invalid_value":"wt/x"}}' >&2
exit 2
"#,
);
std::env::set_var("OCTL_CREATE_SH", &script);
let prompt = dir.path().join("p.md");
std::fs::write(&prompt, "do thing").unwrap();
let err = run_create_sh(&SpawnRequest {
kind: "spinoff",
branch: "wt/x",
prompt_file: &prompt,
layout: None,
no_hooks: false,
keep_tmux_on_error: false,
agent_startup_timeout: 90,
parent_session: None,
source_branch: None,
cwd: None,
})
.unwrap_err();
assert_eq!(err.code, "create_sh_error_branch-exists");
assert_eq!(err.invalid_value.as_deref(), Some("wt/x"));
assert!(matches!(err.kind, ExitKind::System));
std::env::remove_var("OCTL_CREATE_SH");
}
#[test]
fn maps_exit_1_to_user() {
let _g = ENV_LOCK.lock().unwrap();
let dir = TempDir::new().unwrap();
let script = fixture_script(
dir.path(),
r#"#!/bin/bash
echo '{"schema_version":1,"error":{"code":"workmux-failed","message":"workmux add returned non-zero"}}' >&2
exit 1
"#,
);
std::env::set_var("OCTL_CREATE_SH", &script);
let prompt = dir.path().join("p.md");
std::fs::write(&prompt, "x").unwrap();
let err = run_create_sh(&SpawnRequest {
kind: "spinoff",
branch: "wt/x",
prompt_file: &prompt,
layout: None,
no_hooks: false,
keep_tmux_on_error: false,
agent_startup_timeout: 90,
parent_session: None,
source_branch: None,
cwd: None,
})
.unwrap_err();
assert!(matches!(err.kind, ExitKind::User));
assert_eq!(err.code, "create_sh_error_workmux-failed");
std::env::remove_var("OCTL_CREATE_SH");
}
#[test]
fn write_prompt_file_roundtrip() {
let dir = TempDir::new().unwrap();
let p = write_prompt_file(dir.path(), "hello").unwrap();
assert_eq!(std::fs::read_to_string(p).unwrap(), "hello");
}
#[derive(Clone)]
struct BufWriter(std::sync::Arc<Mutex<Vec<u8>>>);
impl std::io::Write for BufWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for BufWriter {
type Writer = BufWriter;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
fn run_fixture(dir: &Path, body: &str) -> Result<SpawnOutcome, CliError> {
let script = fixture_script(dir, body);
std::env::set_var("OCTL_CREATE_SH", &script);
let prompt = dir.join("p.md");
std::fs::write(&prompt, "x").unwrap();
let out = run_create_sh(&SpawnRequest {
kind: "spinoff",
branch: "wt/x",
prompt_file: &prompt,
layout: None,
no_hooks: false,
keep_tmux_on_error: false,
agent_startup_timeout: 90,
parent_session: None,
source_branch: None,
cwd: None,
});
std::env::remove_var("OCTL_CREATE_SH");
out
}
#[test]
fn parses_qualified_tmux_identity() {
let _g = ENV_LOCK.lock().unwrap();
let dir = TempDir::new().unwrap();
let me = std::process::id();
let out = run_fixture(
dir.path(),
&format!(
r#"#!/bin/bash
cat <<EOF
{{"schema_version":1,"type":"spinoff","branch":"wt/x","worktree_path":"/tmp/x","tmux_window":"🚀 wt/x","agent_pid_hint":{me},"workmux_session":"octl","tmux_socket":"/private/tmp/tmux-501/default","tmux_session":"octl","tmux_window_id":"@42","tmux_pane_id":"%7"}}
EOF
"#
),
)
.unwrap();
let id = out.tmux_identity().expect("qualified identity present");
assert_eq!(id.socket.as_deref(), Some("/private/tmp/tmux-501/default"));
assert_eq!(id.session, "octl");
assert_eq!(id.window_id, "@42");
assert_eq!(id.pane_id.as_deref(), Some("%7"));
assert_eq!(id.capture_target(), "%7");
}
#[test]
fn qualified_identity_omits_pane_id_on_legacy_create_sh() {
let _g = ENV_LOCK.lock().unwrap();
let dir = TempDir::new().unwrap();
let me = std::process::id();
let out = run_fixture(
dir.path(),
&format!(
r#"#!/bin/bash
cat <<EOF
{{"schema_version":1,"type":"spinoff","branch":"wt/x","worktree_path":"/tmp/x","tmux_window":"🚀 wt/x","agent_pid_hint":{me},"workmux_session":"octl","tmux_socket":null,"tmux_session":"octl","tmux_window_id":"@42"}}
EOF
"#
),
)
.unwrap();
let id = out
.tmux_identity()
.expect("identity present without pane_id");
assert_eq!(id.pane_id, None);
assert_eq!(id.capture_target(), "@42");
}
#[test]
fn qualified_identity_tolerates_null_socket() {
let _g = ENV_LOCK.lock().unwrap();
let dir = TempDir::new().unwrap();
let me = std::process::id();
let out = run_fixture(
dir.path(),
&format!(
r#"#!/bin/bash
cat <<EOF
{{"schema_version":1,"type":"spinoff","branch":"wt/x","worktree_path":"/tmp/x","tmux_window":"🚀 wt/x","agent_pid_hint":{me},"workmux_session":"octl","tmux_socket":null,"tmux_session":"octl","tmux_window_id":"@7"}}
EOF
"#
),
)
.unwrap();
let id = out
.tmux_identity()
.expect("identity present even without socket");
assert_eq!(id.socket, None);
assert_eq!(id.window_id, "@7");
}
#[test]
fn forwards_parent_session_flag() {
let _g = ENV_LOCK.lock().unwrap();
let dir = TempDir::new().unwrap();
let me = std::process::id();
let script = fixture_script(
dir.path(),
&format!(
r#"#!/bin/bash
sess="none"
while [[ $# -gt 0 ]]; do
case "$1" in
--parent-session) sess="$2"; shift 2 ;;
*) shift ;;
esac
done
cat <<EOF
{{"schema_version":1,"type":"spinoff","branch":"wt/x","worktree_path":"/tmp/x","tmux_window":"🚀 wt/x","agent_pid_hint":{me},"tmux_session":"$sess","tmux_window_id":"@9"}}
EOF
"#
),
);
std::env::set_var("OCTL_CREATE_SH", &script);
let prompt = dir.path().join("p.md");
std::fs::write(&prompt, "x").unwrap();
let with = run_create_sh(&SpawnRequest {
kind: "spinoff",
branch: "wt/x",
prompt_file: &prompt,
layout: None,
no_hooks: false,
keep_tmux_on_error: false,
agent_startup_timeout: 90,
parent_session: Some("headless"),
source_branch: None,
cwd: None,
})
.unwrap();
assert_eq!(with.tmux_session.as_deref(), Some("headless"));
let without = run_create_sh(&SpawnRequest {
kind: "spinoff",
branch: "wt/x",
prompt_file: &prompt,
layout: None,
no_hooks: false,
keep_tmux_on_error: false,
agent_startup_timeout: 90,
parent_session: None,
source_branch: None,
cwd: None,
})
.unwrap();
assert_eq!(without.tmux_session.as_deref(), Some("none"));
std::env::remove_var("OCTL_CREATE_SH");
}
#[test]
fn forwards_base_flag_from_source_branch() {
let _g = ENV_LOCK.lock().unwrap();
let dir = TempDir::new().unwrap();
let me = std::process::id();
let script = fixture_script(
dir.path(),
&format!(
r#"#!/bin/bash
base="none"
while [[ $# -gt 0 ]]; do
case "$1" in
--base) base="$2"; shift 2 ;;
*) shift ;;
esac
done
cat <<EOF
{{"schema_version":1,"type":"orchestrated","branch":"wt/x","worktree_path":"/tmp/x","tmux_window":"🎼 wt/x","agent_pid_hint":{me},"tmux_session":"$base","tmux_window_id":"@9"}}
EOF
"#
),
);
std::env::set_var("OCTL_CREATE_SH", &script);
let prompt = dir.path().join("p.md");
std::fs::write(&prompt, "x").unwrap();
let with = run_create_sh(&SpawnRequest {
kind: "orchestrated",
branch: "wt/x",
prompt_file: &prompt,
layout: None,
no_hooks: false,
keep_tmux_on_error: false,
agent_startup_timeout: 90,
parent_session: None,
source_branch: Some("orchestrate/integration"),
cwd: None,
})
.unwrap();
assert_eq!(
with.tmux_session.as_deref(),
Some("orchestrate/integration")
);
let without = run_create_sh(&SpawnRequest {
kind: "orchestrated",
branch: "wt/x",
prompt_file: &prompt,
layout: None,
no_hooks: false,
keep_tmux_on_error: false,
agent_startup_timeout: 90,
parent_session: None,
source_branch: None,
cwd: None,
})
.unwrap();
assert_eq!(without.tmux_session.as_deref(), Some("none"));
std::env::remove_var("OCTL_CREATE_SH");
}
#[test]
fn forwards_agent_startup_timeout_flag() {
let _g = ENV_LOCK.lock().unwrap();
let dir = TempDir::new().unwrap();
let me = std::process::id();
let script = fixture_script(
dir.path(),
&format!(
r#"#!/bin/bash
to="none"
while [[ $# -gt 0 ]]; do
case "$1" in
--agent-startup-timeout) to="$2"; shift 2 ;;
*) shift ;;
esac
done
cat <<EOF
{{"schema_version":1,"type":"spinoff","branch":"wt/x","worktree_path":"/tmp/x","tmux_window":"🚀 wt/x","agent_pid_hint":{me},"tmux_session":"$to","tmux_window_id":"@9"}}
EOF
"#
),
);
std::env::set_var("OCTL_CREATE_SH", &script);
let prompt = dir.path().join("p.md");
std::fs::write(&prompt, "x").unwrap();
let with = run_create_sh(&SpawnRequest {
kind: "spinoff",
branch: "wt/x",
prompt_file: &prompt,
layout: None,
no_hooks: false,
keep_tmux_on_error: false,
agent_startup_timeout: 180,
parent_session: None,
source_branch: None,
cwd: None,
})
.unwrap();
assert_eq!(with.tmux_session.as_deref(), Some("180"));
let defaulted = run_create_sh(&SpawnRequest {
kind: "spinoff",
branch: "wt/x",
prompt_file: &prompt,
layout: None,
no_hooks: false,
keep_tmux_on_error: false,
agent_startup_timeout: 90,
parent_session: None,
source_branch: None,
cwd: None,
})
.unwrap();
assert_eq!(defaulted.tmux_session.as_deref(), Some("90"));
std::env::remove_var("OCTL_CREATE_SH");
}
#[test]
fn back_compat_missing_identity_is_none_and_warns() {
let _g = ENV_LOCK.lock().unwrap();
let dir = TempDir::new().unwrap();
let me = std::process::id();
let body = format!(
r#"#!/bin/bash
cat <<EOF
{{"schema_version":1,"type":"spinoff","branch":"wt/x","worktree_path":"/tmp/x","tmux_window":"🚀 wt/x","agent_pid_hint":{me},"workmux_session":"octl"}}
EOF
"#
);
let buf = std::sync::Arc::new(Mutex::new(Vec::new()));
let subscriber = tracing_subscriber::fmt()
.with_writer(BufWriter(buf.clone()))
.with_max_level(tracing::Level::WARN)
.finish();
let out = tracing::subscriber::with_default(subscriber, || {
run_fixture(dir.path(), &body).unwrap()
});
assert!(out.tmux_identity().is_none());
assert_eq!(out.tmux_session, None);
assert_eq!(out.tmux_window_id, None);
let logged = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
assert!(
logged.contains("qualified tmux identity"),
"expected back-compat warning, got: {logged:?}"
);
}
fn run_retry_fixture(dir: &Path, body: &str) -> Result<SpawnOutcome, CliError> {
let script = fixture_script(dir, body);
std::env::set_var("OCTL_CREATE_SH", &script);
std::env::set_var("OCTL_TMUX_RETRY_BACKOFF_MS", "0");
let prompt = dir.join("p.md");
std::fs::write(&prompt, "x").unwrap();
let out = run_create_sh_with_tmux_retry(&SpawnRequest {
kind: "spinoff",
branch: "wt/x",
prompt_file: &prompt,
layout: None,
no_hooks: false,
keep_tmux_on_error: false,
agent_startup_timeout: 90,
parent_session: None,
source_branch: None,
cwd: None,
});
std::env::remove_var("OCTL_TMUX_RETRY_BACKOFF_MS");
std::env::remove_var("OCTL_CREATE_SH");
out
}
#[test]
fn tmux_retry_recovers_after_transient_window_not_found() {
let _g = ENV_LOCK.lock().unwrap();
let dir = TempDir::new().unwrap();
let me = std::process::id();
let counter = dir.path().join("attempts");
let body = format!(
r#"#!/bin/bash
n=0
if [[ -f "{c}" ]]; then n=$(cat "{c}"); fi
n=$((n+1))
echo "$n" > "{c}"
if [[ "$n" -lt 2 ]]; then
echo '{{"schema_version":1,"error":{{"code":"tmux-window-not-found","message":"No tmux window"}}}}' >&2
exit 1
fi
cat <<EOF
{{"schema_version":1,"type":"spinoff","branch":"wt/x","worktree_path":"/tmp/x","tmux_window":"🚀 wt/x","agent_pid_hint":{me},"tmux_session":"headless","tmux_window_id":"@9"}}
EOF
"#,
c = counter.display()
);
let out = run_retry_fixture(dir.path(), &body).expect("retry should recover");
assert_eq!(out.branch, "wt/x");
assert_eq!(
std::fs::read_to_string(&counter).unwrap().trim(),
"2",
"expected exactly one retry after the transient failure"
);
}
#[test]
fn tmux_retry_gives_up_after_bound_and_surfaces_error() {
let _g = ENV_LOCK.lock().unwrap();
let dir = TempDir::new().unwrap();
let counter = dir.path().join("attempts");
let body = format!(
r#"#!/bin/bash
n=0
if [[ -f "{c}" ]]; then n=$(cat "{c}"); fi
echo "$((n+1))" > "{c}"
echo '{{"schema_version":1,"error":{{"code":"tmux-window-not-found","message":"No tmux window"}}}}' >&2
exit 1
"#,
c = counter.display()
);
let err = run_retry_fixture(dir.path(), &body).unwrap_err();
assert_eq!(err.code, "create_sh_error_tmux-window-not-found");
assert_eq!(
std::fs::read_to_string(&counter).unwrap().trim(),
(TMUX_MAX_RETRIES + 1).to_string(),
"expected initial attempt plus the full retry budget"
);
}
#[test]
fn tmux_retry_surfaces_a_different_error_from_a_later_attempt() {
let _g = ENV_LOCK.lock().unwrap();
let dir = TempDir::new().unwrap();
let counter = dir.path().join("attempts");
let body = format!(
r#"#!/bin/bash
n=0
if [[ -f "{c}" ]]; then n=$(cat "{c}"); fi
n=$((n+1))
echo "$n" > "{c}"
if [[ "$n" -lt 2 ]]; then
echo '{{"schema_version":1,"error":{{"code":"tmux-window-not-found","message":"No tmux window"}}}}' >&2
exit 1
fi
echo '{{"schema_version":1,"error":{{"code":"branch-exists","message":"branch already exists"}}}}' >&2
exit 2
"#,
c = counter.display()
);
let err = run_retry_fixture(dir.path(), &body).unwrap_err();
assert_eq!(err.code, "create_sh_error_branch-exists");
assert_eq!(
std::fs::read_to_string(&counter).unwrap().trim(),
"2",
"the non-transient error on attempt 2 must be surfaced immediately, no further retries"
);
}
#[test]
fn tmux_retry_does_not_retry_other_errors() {
let _g = ENV_LOCK.lock().unwrap();
let dir = TempDir::new().unwrap();
let counter = dir.path().join("attempts");
let body = format!(
r#"#!/bin/bash
n=0
if [[ -f "{c}" ]]; then n=$(cat "{c}"); fi
echo "$((n+1))" > "{c}"
echo '{{"schema_version":1,"error":{{"code":"branch-exists","message":"branch already exists"}}}}' >&2
exit 2
"#,
c = counter.display()
);
let err = run_retry_fixture(dir.path(), &body).unwrap_err();
assert_eq!(err.code, "create_sh_error_branch-exists");
assert_eq!(
std::fs::read_to_string(&counter).unwrap().trim(),
"1",
"a non-transient error must not be retried"
);
}
}