#[cfg(test)]
use std::process::{Command, Stdio};
use octl_core::{
append_and_apply_event, read_manifest_opt, read_node_opt, Node, NodeId, RunPaths, Status,
VIA_EXPLICIT_MERGE,
};
use crate::git::repo::Git;
use crate::multiplexer::tmux::Tmux;
use serde_json::json;
use tracing::{info, warn};
pub(crate) fn tmux_bin() -> String {
std::env::var("TMUX_BIN").unwrap_or_else(|_| "tmux".to_string())
}
pub(crate) fn git_bin() -> String {
std::env::var("GIT_BIN").unwrap_or_else(|_| "git".to_string())
}
pub fn rollup_status(paths: &RunPaths, children_all_terminal: bool) -> Option<Status> {
let manifest = read_manifest_opt(paths).ok().flatten()?;
if manifest.status.is_terminal() {
return None;
}
if !children_all_terminal {
return None;
}
let nodes = list_nodes(paths);
if nodes.is_empty() {
return None;
}
let mut any_failed = false;
for n in &nodes {
match n.status {
Status::Done => {}
Status::Failed | Status::Cancelled => any_failed = true,
Status::Pending | Status::Running | Status::Blocked => return None,
}
}
Some(if any_failed {
Status::Failed
} else {
Status::Done
})
}
pub fn any_node_merged_explicitly(paths: &RunPaths) -> bool {
list_nodes(paths).iter().any(node_merged_explicitly)
}
fn node_merged_explicitly(n: &Node) -> bool {
report_via(n) == Some(VIA_EXPLICIT_MERGE)
}
pub const VIA_MERGE_RECONCILED: &str = "merge-reconciled";
fn report_via(n: &Node) -> Option<&str> {
n.last_report
.as_ref()
.and_then(|r| r.get("via"))
.and_then(serde_json::Value::as_str)
}
fn node_branch_merged(n: &Node) -> bool {
let Some(report) = n.last_report.as_ref() else {
return false;
};
let success = report.get("success").and_then(serde_json::Value::as_bool) == Some(true);
success
&& matches!(
report_via(n),
Some(VIA_EXPLICIT_MERGE | VIA_MERGE_RECONCILED)
)
}
fn node_report_is_blocked(n: &Node) -> bool {
let Some(report) = n.last_report.as_ref() else {
return false;
};
let success_false = report.get("success").and_then(serde_json::Value::as_bool) == Some(false);
let cancelled = report.get("cancelled").and_then(serde_json::Value::as_bool) == Some(true);
success_false && !cancelled && !node_merged_explicitly(n)
}
pub fn cleanup_terminal_nodes(paths: &RunPaths) {
let tmux = tmux_bin();
let git = git_bin();
for n in list_nodes(paths) {
cleanup_node(paths, &n, &tmux, &git);
}
}
pub fn cleanup_managed_session(paths: &RunPaths) {
cleanup_managed_session_with(paths, &tmux_bin());
}
fn cleanup_managed_session_with(paths: &RunPaths, tmux: &str) {
let Some(session) = managed_session(paths) else {
return;
};
let socket = managed_session_socket(paths, &session);
let mux = Tmux::with_bin(tmux);
let Some((attached, names)) = mux.list_session_windows(socket.as_deref(), &session) else {
return;
};
if attached {
record_session_retained(paths, &session);
return;
}
if names.is_empty() || !names.iter().all(|n| is_synthetic_default_window(n)) {
return;
}
if mux.kill_session(socket.as_deref(), &session) {
record_session_killed(paths, &session);
}
}
fn managed_session(paths: &RunPaths) -> Option<String> {
read_manifest_opt(paths)
.ok()
.flatten()
.and_then(|m| m.managed_tmux_session)
}
fn managed_session_socket(paths: &RunPaths, session: &str) -> Option<String> {
for n in list_nodes(paths) {
if let Some(id) = n.tmux_identity {
if id.session == session {
if let Some(sock) = id.socket {
if !sock.is_empty() {
return Some(sock);
}
}
}
}
}
None
}
fn is_synthetic_default_window(name: &str) -> bool {
matches!(
name.trim(),
"zsh" | "bash" | "sh" | "fish" | "-zsh" | "-bash" | "-sh"
)
}
fn record_session_killed(paths: &RunPaths, session: &str) {
info!(
target: "orchestratectl::supervise",
session,
"killed empty managed headless tmux session after last managed window torn down"
);
eprintln!("supervisor cleanup: tmux kill-session -t {session}: ok (empty managed session)");
let data = json!({ "session": session });
let key = format!("cleanup.session_killed:{}", paths.run_id.as_str());
if let Err(e) = append_and_apply_event(paths, "cleanup.session_killed", None, Some(&key), data)
{
warn!(
target: "orchestratectl::supervise",
session,
error = %e,
"failed to append cleanup.session_killed (continuing)"
);
}
}
fn record_session_retained(paths: &RunPaths, session: &str) {
info!(
target: "orchestratectl::supervise",
session,
"managed headless tmux session is attached; leaving it for the human"
);
eprintln!("supervisor cleanup: tmux session {session} attached; not killing (recorded cleanup.session_retained)");
let data = json!({ "session": session });
let key = format!("cleanup.session_retained:{}", paths.run_id.as_str());
if let Err(e) =
append_and_apply_event(paths, "cleanup.session_retained", None, Some(&key), data)
{
warn!(
target: "orchestratectl::supervise",
session,
error = %e,
"failed to append cleanup.session_retained (continuing)"
);
}
}
pub(crate) fn cleanup_node(paths: &RunPaths, n: &Node, tmux: &str, git: &str) {
close_tmux_window(paths, n, tmux);
let Some(worktree_path) = n.worktree_path.as_deref() else {
return;
};
if node_report_is_blocked(n) {
record_branch_preserved(
paths,
n,
n.branch.as_deref(),
worktree_path,
"blocked report",
);
return;
}
let main_repo = main_worktree_of(worktree_path, git).or_else(|| manifest_source_repo(paths));
let repo = main_repo.as_deref().unwrap_or(worktree_path);
let merged = node_branch_merged(n);
let skip_source_check = node_merged_explicitly(n);
if !skip_source_check {
if let (Some(branch), Some(source)) = (n.branch.as_deref(), manifest_source_branch(paths)) {
if branch_has_unmerged_commits(repo, &source, branch, git) {
record_branch_preserved(
paths,
n,
Some(branch),
worktree_path,
"unmerged commits vs source (no explicit merge)",
);
return;
}
}
}
if !remove_worktree(repo, worktree_path, git) && !std::path::Path::new(worktree_path).exists() {
record_worktree_missing(paths, n, worktree_path);
}
if let Some(branch) = n.branch.as_deref() {
let _ = delete_branch(paths, n, repo, branch, git, merged);
}
}
fn branch_has_unmerged_commits(repo: &str, source: &str, branch: &str, git: &str) -> bool {
Git::with_bin(git)
.rev_list_count(repo, source, branch)
.is_some_and(|count| count > 0)
}
pub fn node_branch_merged_to_source(paths: &RunPaths, n: &Node, git: &str) -> bool {
let node_id = n.node_id.as_str();
let Some(branch) = n.branch.as_deref().filter(|s| !s.is_empty()) else {
return false;
};
let Some(base) = n.base_sha.as_deref().filter(|s| !s.is_empty()) else {
return false;
};
let Some(manifest) = read_manifest_opt(paths).ok().flatten() else {
return false;
};
let Some(source) = manifest.source_branch.as_deref().filter(|s| !s.is_empty()) else {
return false;
};
let repo = manifest
.source_repo
.as_deref()
.filter(|s| !s.is_empty())
.or(n.worktree_path.as_deref());
let Some(repo) = repo else {
return false;
};
if !git_is_ancestor(repo, branch, source, git) {
return false;
}
match git_ahead_count(repo, base, branch, git) {
Some(ahead) if ahead > 0 => {}
_ => {
tracing::debug!(
target: "orchestratectl::supervise",
node = node_id, branch, "reconcile declined: branch not advanced past its fork point"
);
return false;
}
}
if !worktree_is_clean(n.worktree_path.as_deref(), git) {
tracing::debug!(
target: "orchestratectl::supervise",
node = node_id, branch, "reconcile declined: worktree has uncommitted work"
);
return false;
}
true
}
fn git_ahead_count(repo: &str, base: &str, branch: &str, git: &str) -> Option<u64> {
Git::with_bin(git).rev_list_count(repo, base, branch)
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Recoverability {
pub unmerged_commits: u64,
pub merges_cleanly: bool,
pub branch: String,
pub worktree_path: Option<String>,
}
impl Recoverability {
#[must_use]
pub fn recoverable(&self) -> bool {
self.unmerged_commits > 0 && self.merges_cleanly
}
#[must_use]
pub fn to_report_value(&self) -> serde_json::Value {
serde_json::json!({
"recoverable": self.recoverable(),
"unmerged_commits": self.unmerged_commits,
"merges_cleanly": self.merges_cleanly,
"branch": self.branch,
"worktree_path": self.worktree_path,
})
}
}
pub fn node_recoverability(paths: &RunPaths, n: &Node, git: &str) -> Option<Recoverability> {
let branch = n.branch.as_deref().filter(|s| !s.is_empty())?;
let manifest = read_manifest_opt(paths).ok().flatten()?;
let source = manifest
.source_branch
.as_deref()
.filter(|s| !s.is_empty())?;
let repo = manifest
.source_repo
.as_deref()
.filter(|s| !s.is_empty())
.or(n.worktree_path.as_deref())?;
let unmerged_commits = match git_ahead_count(repo, source, branch, git) {
Some(ahead) if ahead > 0 => ahead,
_ => return None,
};
let merges_cleanly = branch_merges_cleanly(repo, source, branch, git);
Some(Recoverability {
unmerged_commits,
merges_cleanly,
branch: branch.to_string(),
worktree_path: n
.worktree_path
.as_deref()
.filter(|s| !s.is_empty())
.map(str::to_string),
})
}
pub fn node_is_empty_handed(paths: &RunPaths, n: &Node, git: &str) -> bool {
let Some(branch) = n.branch.as_deref().filter(|s| !s.is_empty()) else {
return false;
};
let Some(manifest) = read_manifest_opt(paths).ok().flatten() else {
return false;
};
let Some(source) = manifest.source_branch.as_deref().filter(|s| !s.is_empty()) else {
return false;
};
let repo = match manifest
.source_repo
.as_deref()
.filter(|s| !s.is_empty())
.or(n.worktree_path.as_deref())
{
Some(r) => r,
None => return false,
};
matches!(git_ahead_count(repo, source, branch, git), Some(0))
&& worktree_is_clean(n.worktree_path.as_deref(), git)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdleUnmerged {
pub recoverability: Recoverability,
pub idle_secs: i64,
}
fn branch_tip_committer_time(repo: &str, branch: &str, git: &str) -> Option<i64> {
Git::with_bin(git).tip_committer_time(repo, branch)
}
fn agent_log_mtime(paths: &RunPaths) -> Option<i64> {
let meta = std::fs::metadata(paths.agent_log()).ok()?;
let mtime = meta.modified().ok()?;
mtime
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|d| d.as_secs() as i64)
}
pub fn node_idle_unmerged(
paths: &RunPaths,
n: &Node,
git: &str,
now_unix: i64,
idle_threshold_secs: i64,
extra_activity_unix: Option<i64>,
) -> Option<IdleUnmerged> {
let branch = n.branch.as_deref().filter(|s| !s.is_empty())?;
let manifest = read_manifest_opt(paths).ok().flatten()?;
let repo = manifest
.source_repo
.as_deref()
.filter(|s| !s.is_empty())
.or(n.worktree_path.as_deref())?;
let tip = branch_tip_committer_time(repo, branch, git);
let log_mtime = agent_log_mtime(paths);
let last_activity = tip
.into_iter()
.chain(log_mtime)
.chain(extra_activity_unix)
.max()?;
let idle_secs = now_unix.saturating_sub(last_activity);
if idle_secs < idle_threshold_secs {
return None;
}
if !worktree_is_clean(n.worktree_path.as_deref(), git) {
return None;
}
let recoverability = node_recoverability(paths, n, git)?;
Some(IdleUnmerged {
recoverability,
idle_secs,
})
}
fn branch_merges_cleanly(repo: &str, source: &str, branch: &str, git: &str) -> bool {
if git_is_ancestor(repo, source, branch, git) {
return true;
}
Git::with_bin(git).merge_tree_clean(repo, source, branch)
}
fn worktree_is_clean(worktree_path: Option<&str>, git: &str) -> bool {
let Some(wt) = worktree_path.filter(|s| !s.is_empty()) else {
return true;
};
if !std::path::Path::new(wt).exists() {
return true;
}
Git::with_bin(git).worktree_is_clean(wt)
}
fn git_is_ancestor(repo: &str, ancestor: &str, descendant: &str, git: &str) -> bool {
Git::with_bin(git).is_ancestor(repo, ancestor, descendant)
}
fn manifest_source_repo(paths: &RunPaths) -> Option<String> {
read_manifest_opt(paths)
.ok()
.flatten()
.and_then(|m| m.source_repo)
}
fn manifest_source_branch(paths: &RunPaths) -> Option<String> {
read_manifest_opt(paths)
.ok()
.flatten()
.and_then(|m| m.source_branch)
}
fn close_tmux_window(paths: &RunPaths, n: &Node, tmux: &str) {
let socket = n
.tmux_identity
.as_ref()
.and_then(|id| id.socket.as_deref())
.filter(|s| !s.is_empty());
let target = match n.tmux_identity.as_ref() {
Some(id) => id.window_id.clone(),
None => match n.tmux_window.as_deref() {
Some(name) => name.to_string(),
None => return,
},
};
let mux = Tmux::with_bin(tmux);
if mux.kill_window(socket, &target) {
return;
}
let session = n
.tmux_identity
.as_ref()
.map(|id| id.session.as_str())
.filter(|s| !s.is_empty());
if let Some(worktree) = n.worktree_path.as_deref() {
if let Some(recovered) = mux.find_window_by_path(socket, session, worktree) {
if recovered != target && mux.kill_window(socket, &recovered) {
info!(
target: "orchestratectl::supervise",
node = %n.node_id,
primary = %target,
recovered = %recovered,
"tmux window recovered by worktree path after the recorded target was missing"
);
eprintln!(
"supervisor cleanup: tmux window {target} missing; closed {recovered} found by worktree path"
);
return;
}
}
}
record_window_missing(paths, n, &target);
}
fn record_window_missing(paths: &RunPaths, n: &Node, target: &str) {
let method = if n.tmux_identity.is_some() {
"window-id"
} else {
"window-name"
};
warn!(
target: "orchestratectl::supervise",
node = %n.node_id,
window = %target,
method,
"tmux window not found during cleanup; recording cleanup.window_missing (run not failed)"
);
eprintln!(
"supervisor cleanup: tmux window {target} not found (continuing; recorded cleanup.window_missing)"
);
let data = json!({
"node_id": n.node_id.as_str(),
"window": target,
"method": method,
"worktree_path": n.worktree_path,
});
let key = format!(
"cleanup.window_missing:{}:{}",
paths.run_id.as_str(),
n.node_id.as_str()
);
if let Err(e) = append_and_apply_event(
paths,
"cleanup.window_missing",
Some(&n.node_id),
Some(&key),
data,
) {
warn!(
target: "orchestratectl::supervise",
node = %n.node_id,
error = %e,
"failed to append cleanup.window_missing (continuing)"
);
}
}
fn record_worktree_missing(paths: &RunPaths, n: &Node, worktree_path: &str) {
warn!(
target: "orchestratectl::supervise",
node = %n.node_id,
worktree_path,
"worktree dir already gone during cleanup; recording cleanup.worktree_missing (run not failed)"
);
eprintln!(
"supervisor cleanup: worktree {worktree_path} already gone (continuing; recorded cleanup.worktree_missing)"
);
let data = json!({
"node_id": n.node_id.as_str(),
"worktree_path": worktree_path,
"branch": n.branch,
});
let key = format!(
"cleanup.worktree_missing:{}:{}",
paths.run_id.as_str(),
n.node_id.as_str()
);
if let Err(e) = append_and_apply_event(
paths,
"cleanup.worktree_missing",
Some(&n.node_id),
Some(&key),
data,
) {
warn!(
target: "orchestratectl::supervise",
node = %n.node_id,
error = %e,
"failed to append cleanup.worktree_missing (continuing)"
);
}
}
fn record_branch_remove_failed(paths: &RunPaths, n: &Node, branch: &str, detail: &str) {
warn!(
target: "orchestratectl::supervise",
node = %n.node_id,
branch,
detail,
"git branch -D failed during cleanup; recording cleanup.branch_remove_failed (run not failed)"
);
eprintln!(
"supervisor cleanup: branch {branch} delete failed (continuing; recorded cleanup.branch_remove_failed): {detail}"
);
let data = json!({
"node_id": n.node_id.as_str(),
"branch": branch,
"error": detail,
});
let key = format!(
"cleanup.branch_remove_failed:{}:{}",
paths.run_id.as_str(),
n.node_id.as_str()
);
if let Err(e) = append_and_apply_event(
paths,
"cleanup.branch_remove_failed",
Some(&n.node_id),
Some(&key),
data,
) {
warn!(
target: "orchestratectl::supervise",
node = %n.node_id,
error = %e,
"failed to append cleanup.branch_remove_failed (continuing)"
);
}
}
fn record_branch_preserved(
paths: &RunPaths,
n: &Node,
branch: Option<&str>,
worktree_path: &str,
reason: &str,
) {
let branch_display = branch.unwrap_or("<none>");
info!(
target: "orchestratectl::supervise",
node = %n.node_id,
branch = branch_display,
worktree_path,
reason,
"preserving branch + worktree for the human (not tearing down)"
);
eprintln!(
"supervisor cleanup: branch {branch_display} left unmerged for you to merge ({reason}; worktree preserved at {worktree_path})"
);
let data = json!({
"node_id": n.node_id.as_str(),
"branch": branch,
"worktree_path": worktree_path,
"reason": reason,
});
let key = format!(
"cleanup.branch_preserved:{}:{}",
paths.run_id.as_str(),
n.node_id.as_str()
);
if let Err(e) = append_and_apply_event(
paths,
"cleanup.branch_preserved",
Some(&n.node_id),
Some(&key),
data,
) {
warn!(
target: "orchestratectl::supervise",
node = %n.node_id,
error = %e,
"failed to append cleanup.branch_preserved (continuing)"
);
}
}
fn main_worktree_of(worktree_path: &str, git: &str) -> Option<String> {
Git::with_bin(git).main_worktree(worktree_path)
}
fn remove_worktree(repo: &str, worktree_path: &str, git: &str) -> bool {
Git::with_bin(git).worktree_remove(repo, worktree_path)
}
fn delete_branch(
paths: &RunPaths,
n: &Node,
repo: &str,
branch: &str,
git: &str,
merged: bool,
) -> Option<String> {
match Git::with_bin(git).branch_delete(repo, branch, merged) {
Some(detail) => {
record_branch_remove_failed(paths, n, branch, &detail);
Some(detail)
}
None => None,
}
}
fn list_nodes(paths: &RunPaths) -> Vec<Node> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(paths.nodes_dir()) else {
return out;
};
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let Ok(nid) = NodeId::parse_str(stem) else {
continue;
};
if let Ok(Some(n)) = read_node_opt(paths, &nid) {
out.push(n);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use octl_core::{append_and_apply_event, NodeId, RunPaths};
use serde_json::json;
use tempfile::TempDir;
fn fresh_run(tmp: &TempDir) -> RunPaths {
let run_id = "01jxsnap000000000000000000";
let dir = tmp.path().join(run_id);
std::fs::create_dir_all(&dir).unwrap();
RunPaths::new(dir, run_id).unwrap()
}
fn nid(s: &str) -> NodeId {
NodeId::parse_str(s).unwrap()
}
fn bootstrap(paths: &RunPaths, count: usize) {
append_and_apply_event(
paths,
"run.created",
None,
None,
json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
)
.unwrap();
for i in 1..=count {
append_and_apply_event(
paths,
"node.created",
Some(&nid(&format!("n-{i:04}"))),
None,
json!({ "kind": "spinoff" }),
)
.unwrap();
}
}
fn report(paths: &RunPaths, node: &str, data: serde_json::Value) {
append_and_apply_event(paths, "node.report", Some(&nid(node)), None, data).unwrap();
}
fn forge_node(paths: &RunPaths, node: &str, mut data: serde_json::Value) -> Node {
let obj = data.as_object_mut().unwrap();
obj.entry("kind").or_insert_with(|| json!("spinoff"));
append_and_apply_event(paths, "node.created", Some(&nid(node)), None, data).unwrap();
read_node_opt(paths, &nid(node)).unwrap().unwrap()
}
fn fake_tmux(dir: &std::path::Path, body: &str) -> String {
use std::os::unix::fs::PermissionsExt as _;
let p = dir.join("fake-tmux.sh");
let log = dir.join("tmux.log");
let script = format!(
"#!/bin/bash\nprintf '%s ' \"$@\" >> '{log}'\nprintf '\\n' >> '{log}'\n{body}\nexit 0\n",
log = log.display(),
);
std::fs::write(&p, script).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
p.to_str().unwrap().to_string()
}
fn tmux_log(dir: &std::path::Path) -> String {
std::fs::read_to_string(dir.join("tmux.log")).unwrap_or_default()
}
fn events_of_kind(paths: &RunPaths, kind: &str) -> Vec<serde_json::Value> {
std::fs::read_to_string(paths.events())
.unwrap_or_default()
.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.filter(|v| v["kind"] == kind)
.collect()
}
fn window_missing_events(paths: &RunPaths) -> Vec<serde_json::Value> {
events_of_kind(paths, "cleanup.window_missing")
}
fn git(cwd: &std::path::Path, args: &[&str]) {
let ok = Command::new("git")
.current_dir(cwd)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap()
.success();
assert!(ok, "git {args:?} failed in {cwd:?}");
}
fn init_repo_with_worktree(tmp: &TempDir) -> (std::path::PathBuf, std::path::PathBuf) {
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q", "-b", "main"]);
git(&repo, &["config", "user.email", "t@example.com"]);
git(&repo, &["config", "user.name", "t"]);
std::fs::write(repo.join("README"), "x").unwrap();
git(&repo, &["add", "-A"]);
git(&repo, &["commit", "-qm", "init"]);
let wt = tmp.path().join("wt");
git(
&repo,
&[
"worktree",
"add",
"-q",
"-b",
"wt/foo",
wt.to_str().unwrap(),
],
);
(repo, wt)
}
fn branch_exists(repo: &std::path::Path, branch: &str) -> bool {
Command::new("git")
.current_dir(repo)
.args(["rev-parse", "--verify", "--quiet", branch])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap()
.success()
}
#[test]
fn window_killed_by_id_no_fallback() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 0);
let n = forge_node(
&paths,
"n-0001",
json!({ "tmux_session": "octl", "tmux_window_id": "@42", "worktree_path": "/fake/wt" }),
);
let tmux = fake_tmux(tmp.path(), "");
close_tmux_window(&paths, &n, &tmux);
let log = tmux_log(tmp.path());
assert!(log.contains("kill-window -t @42"), "log={log:?}");
assert!(!log.contains("list-windows"), "must not probe on success");
assert!(window_missing_events(&paths).is_empty());
}
#[test]
fn missing_window_records_event_and_does_not_fail() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 0);
let n = forge_node(
&paths,
"n-0001",
json!({ "tmux_session": "octl", "tmux_window_id": "@42", "worktree_path": "/fake/wt" }),
);
let tmux = fake_tmux(
tmp.path(),
r#"case "$*" in
*kill-window*) exit 1;;
*list-windows*) echo "@99 /some/other/path";;
esac"#,
);
close_tmux_window(&paths, &n, &tmux);
let events = window_missing_events(&paths);
assert_eq!(events.len(), 1, "exactly one audit event expected");
assert_eq!(events[0]["data"]["window"], "@42");
assert_eq!(events[0]["data"]["method"], "window-id");
}
#[test]
fn renamed_window_recovered_by_worktree_path() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 0);
let n = forge_node(
&paths,
"n-0001",
json!({ "tmux_window": "wt/abc-old-name", "worktree_path": "/fake/wt" }),
);
let tmux = fake_tmux(
tmp.path(),
r#"case "$*" in
*'kill-window -t @55'*) exit 0;;
*kill-window*) exit 1;;
*list-windows*) printf '@7\t/other\n@55\t/fake/wt\n';;
esac"#,
);
close_tmux_window(&paths, &n, &tmux);
let log = tmux_log(tmp.path());
assert!(
log.contains("kill-window -t wt/abc-old-name"),
"primary kill attempted first: {log:?}"
);
assert!(
log.contains("kill-window -t @55"),
"recovered window killed by path: {log:?}"
);
assert!(
window_missing_events(&paths).is_empty(),
"recovery must not record a missing-window event"
);
}
#[test]
fn missing_window_event_is_idempotent() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 0);
let n = forge_node(
&paths,
"n-0001",
json!({ "tmux_session": "octl", "tmux_window_id": "@42", "worktree_path": "/fake/wt" }),
);
let tmux = fake_tmux(
tmp.path(),
r#"case "$*" in *kill-window*) exit 1;; *list-windows*) ;; esac"#,
);
close_tmux_window(&paths, &n, &tmux);
close_tmux_window(&paths, &n, &tmux);
assert_eq!(window_missing_events(&paths).len(), 1);
}
#[test]
fn worktree_with_untracked_file_is_force_removed_with_branch() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 0);
let (repo, wt) = init_repo_with_worktree(&tmp);
std::fs::write(wt.join(".report.json"), "scratch").unwrap();
let n = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo" }),
);
cleanup_node(&paths, &n, "/usr/bin/true", &git_bin());
assert!(!wt.exists(), "worktree dir must be force-removed");
assert!(
!branch_exists(&repo, "wt/foo"),
"branch must be deleted once the worktree is gone"
);
assert!(
events_of_kind(&paths, "cleanup.worktree_missing").is_empty(),
"a present (if dirty) worktree is not 'missing'"
);
assert!(
events_of_kind(&paths, "cleanup.branch_remove_failed").is_empty(),
"branch removal must succeed"
);
}
fn commit_in_worktree(wt: &std::path::Path, file: &str, msg: &str) {
std::fs::write(wt.join(file), "work").unwrap();
git(wt, &["add", "-A"]);
git(wt, &["commit", "-qm", msg]);
}
fn commits_ahead(repo: &std::path::Path, base: &str, branch: &str) -> usize {
let out = Command::new("git")
.current_dir(repo)
.args(["rev-list", "--count", &format!("{base}..{branch}")])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().parse().unwrap()
}
#[test]
fn blocked_report_preserves_branch_and_worktree() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 0);
let (repo, wt) = init_repo_with_worktree(&tmp);
commit_in_worktree(&wt, "fix.rs", "agent work");
assert_eq!(commits_ahead(&repo, "main", "wt/foo"), 1);
let _ = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo" }),
);
report(
&paths,
"n-0001",
json!({ "success": false, "discussion_items": [{ "q": "need sudo" }] }),
);
let n = read_node_opt(&paths, &nid("n-0001")).unwrap().unwrap();
cleanup_node(&paths, &n, "/usr/bin/true", &git_bin());
assert!(
branch_exists(&repo, "wt/foo"),
"blocked path must leave the branch for the human"
);
assert!(wt.exists(), "blocked path must preserve the worktree too");
assert_eq!(
commits_ahead(&repo, "main", "wt/foo"),
1,
"the agent's commit must survive on the branch"
);
let evs = events_of_kind(&paths, "cleanup.branch_preserved");
assert_eq!(evs.len(), 1, "the preserved branch must be recorded once");
assert_eq!(evs[0]["data"]["branch"], "wt/foo");
}
#[test]
fn blocked_preserve_event_is_idempotent() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 0);
let (repo, wt) = init_repo_with_worktree(&tmp);
commit_in_worktree(&wt, "fix.rs", "agent work");
let _ = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo" }),
);
report(&paths, "n-0001", json!({ "success": false }));
let n = read_node_opt(&paths, &nid("n-0001")).unwrap().unwrap();
cleanup_node(&paths, &n, "/usr/bin/true", &git_bin());
cleanup_node(&paths, &n, "/usr/bin/true", &git_bin());
assert!(branch_exists(&repo, "wt/foo"));
assert!(wt.exists());
assert_eq!(events_of_kind(&paths, "cleanup.branch_preserved").len(), 1);
}
#[test]
fn explicit_merge_report_still_deletes_branch() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 0);
let (repo, wt) = init_repo_with_worktree(&tmp);
commit_in_worktree(&wt, "feat.rs", "merged work");
assert_eq!(commits_ahead(&repo, "main", "wt/foo"), 1);
let _ = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo" }),
);
report(
&paths,
"n-0001",
json!({ "success": true, "via": "explicit-merge" }),
);
let n = read_node_opt(&paths, &nid("n-0001")).unwrap().unwrap();
cleanup_node(&paths, &n, "/usr/bin/true", &git_bin());
assert!(!wt.exists(), "merge path must remove the worktree");
assert!(
!branch_exists(&repo, "wt/foo"),
"an explicit-merge node's branch is force-deleted as before"
);
assert!(events_of_kind(&paths, "cleanup.branch_preserved").is_empty());
}
#[test]
fn agent_log_survives_teardown() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 0);
let (repo, wt) = init_repo_with_worktree(&tmp);
commit_in_worktree(&wt, "feat.rs", "merged work");
std::fs::write(paths.agent_log(), b"agent stdout line\napi error trace\n").unwrap();
let _ = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo" }),
);
report(
&paths,
"n-0001",
json!({ "success": true, "via": "explicit-merge" }),
);
let n = read_node_opt(&paths, &nid("n-0001")).unwrap().unwrap();
cleanup_node(&paths, &n, "/usr/bin/true", &git_bin());
assert!(!wt.exists(), "merge path must remove the worktree");
assert!(!branch_exists(&repo, "wt/foo"));
assert!(
paths.agent_log().exists(),
"agent.log must survive teardown"
);
assert_eq!(
std::fs::read_to_string(paths.agent_log()).unwrap(),
"agent stdout line\napi error trace\n",
"agent.log content must be intact"
);
}
#[test]
fn unmerged_branch_not_force_deleted_without_explicit_merge() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 0); let (repo, wt) = init_repo_with_worktree(&tmp);
commit_in_worktree(&wt, "x.rs", "unmerged work");
assert_eq!(commits_ahead(&repo, "main", "wt/foo"), 1);
let _ = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo" }),
);
report(&paths, "n-0001", json!({ "success": true }));
let n = read_node_opt(&paths, &nid("n-0001")).unwrap().unwrap();
cleanup_node(&paths, &n, "/usr/bin/true", &git_bin());
assert!(
branch_exists(&repo, "wt/foo"),
"unmerged commits must not be force-dropped without a confirmed merge"
);
assert_eq!(commits_ahead(&repo, "main", "wt/foo"), 1);
let evs = events_of_kind(&paths, "cleanup.branch_remove_failed");
assert_eq!(evs.len(), 1, "the refused delete must be recorded");
assert_eq!(evs[0]["data"]["branch"], "wt/foo");
}
#[test]
fn unmerged_branch_preserves_both_when_source_known() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
append_and_apply_event(
&paths,
"run.created",
None,
None,
json!({
"kind": "spinoff",
"lifecycle": "autonomous",
"title": "t",
"source_branch": "main",
}),
)
.unwrap();
let (repo, wt) = init_repo_with_worktree(&tmp);
commit_in_worktree(&wt, "x.rs", "unmerged work");
assert_eq!(commits_ahead(&repo, "main", "wt/foo"), 1);
let _ = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo" }),
);
report(&paths, "n-0001", json!({ "success": true }));
let n = read_node_opt(&paths, &nid("n-0001")).unwrap().unwrap();
cleanup_node(&paths, &n, "/usr/bin/true", &git_bin());
assert!(
wt.exists(),
"worktree must be preserved on the source-check arm"
);
assert!(branch_exists(&repo, "wt/foo"), "branch must be preserved");
assert_eq!(commits_ahead(&repo, "main", "wt/foo"), 1);
let evs = events_of_kind(&paths, "cleanup.branch_preserved");
assert_eq!(evs.len(), 1, "preservation must be recorded once");
assert_eq!(evs[0]["data"]["branch"], "wt/foo");
assert_eq!(
evs[0]["data"]["reason"], "unmerged commits vs source (no explicit merge)",
"the reason must distinguish this from a blocked report"
);
assert!(events_of_kind(&paths, "cleanup.branch_remove_failed").is_empty());
}
#[test]
fn merged_branch_torn_down_when_source_known() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
append_and_apply_event(
&paths,
"run.created",
None,
None,
json!({
"kind": "spinoff",
"lifecycle": "autonomous",
"title": "t",
"source_branch": "main",
}),
)
.unwrap();
let (repo, wt) = init_repo_with_worktree(&tmp);
assert_eq!(commits_ahead(&repo, "main", "wt/foo"), 0);
let _ = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo" }),
);
report(&paths, "n-0001", json!({ "success": true }));
let n = read_node_opt(&paths, &nid("n-0001")).unwrap().unwrap();
cleanup_node(&paths, &n, "/usr/bin/true", &git_bin());
assert!(!wt.exists(), "a source-merged branch's worktree is removed");
assert!(
!branch_exists(&repo, "wt/foo"),
"a source-merged branch is deleted"
);
assert!(events_of_kind(&paths, "cleanup.branch_preserved").is_empty());
}
#[test]
fn blocked_classification() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 0);
let blocked = forge_node(&paths, "n-0001", json!({ "branch": "wt/a" }));
assert!(!node_report_is_blocked(&blocked));
let cases = [
(json!({ "success": false }), true, "plain blocked handoff"),
(
json!({ "success": false, "cancelled": true }),
false,
"a run-cancel is a deliberate teardown, not a blocked handoff",
),
(
json!({ "success": false, "via": "explicit-merge" }),
false,
"an explicit merge is never blocked",
),
(json!({ "success": true }), false, "success is not blocked"),
];
for (i, (report_data, want, why)) in cases.into_iter().enumerate() {
let node = format!("n-1{i:03}");
let _ = forge_node(&paths, &node, json!({ "branch": "wt/x" }));
report(&paths, &node, report_data);
let n = read_node_opt(&paths, &nid(&node)).unwrap().unwrap();
assert_eq!(node_report_is_blocked(&n), want, "{why}");
}
}
#[test]
fn missing_worktree_records_event_and_continues() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 0);
let gone = tmp.path().join("gone-wt");
let n = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": gone.to_str().unwrap() }),
);
cleanup_node(&paths, &n, "/usr/bin/true", &git_bin());
let evs = events_of_kind(&paths, "cleanup.worktree_missing");
assert_eq!(evs.len(), 1, "exactly one worktree_missing event expected");
assert_eq!(evs[0]["data"]["worktree_path"], gone.to_str().unwrap());
}
#[test]
fn branch_remove_failure_records_event_and_continues() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 0);
let (_repo, wt) = init_repo_with_worktree(&tmp);
let n = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/never-existed" }),
);
cleanup_node(&paths, &n, "/usr/bin/true", &git_bin());
assert!(!wt.exists(), "worktree must still be removed");
let evs = events_of_kind(&paths, "cleanup.branch_remove_failed");
assert_eq!(evs.len(), 1, "branch failure must be recorded once");
assert_eq!(evs[0]["data"]["branch"], "wt/never-existed");
assert!(
!evs[0]["data"]["error"]
.as_str()
.unwrap_or_default()
.is_empty(),
"the git stderr must be captured for the operator"
);
}
fn bootstrap_headless(paths: &RunPaths, session: &str) {
append_and_apply_event(
paths,
"run.created",
None,
None,
json!({
"kind": "spinoff",
"lifecycle": "autonomous",
"title": "t",
"managed_tmux_session": session,
}),
)
.unwrap();
}
#[test]
fn managed_session_killed_when_only_default_window_remains() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_headless(&paths, "headless");
let tmux = fake_tmux(
tmp.path(),
r#"case "$*" in
*list-windows*) printf '0\tzsh\n';;
esac"#,
);
cleanup_managed_session_with(&paths, &tmux);
let log = tmux_log(tmp.path());
assert!(
log.contains("kill-session -t headless"),
"empty managed session must be killed: {log:?}"
);
let evs = events_of_kind(&paths, "cleanup.session_killed");
assert_eq!(evs.len(), 1, "exactly one session_killed event expected");
assert_eq!(evs[0]["data"]["session"], "headless");
}
#[test]
fn managed_session_retained_when_agent_window_remains() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_headless(&paths, "headless");
let tmux = fake_tmux(
tmp.path(),
r#"case "$*" in
*list-windows*) printf '0\tzsh\n0\t🎬 wt/sibling\n';;
esac"#,
);
cleanup_managed_session_with(&paths, &tmux);
let log = tmux_log(tmp.path());
assert!(
!log.contains("kill-session"),
"a live sibling agent window must keep the session alive: {log:?}"
);
assert!(events_of_kind(&paths, "cleanup.session_killed").is_empty());
}
#[test]
fn managed_session_retained_when_attached() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_headless(&paths, "headless");
let tmux = fake_tmux(
tmp.path(),
r#"case "$*" in
*list-windows*) printf '1\tzsh\n';;
esac"#,
);
cleanup_managed_session_with(&paths, &tmux);
let log = tmux_log(tmp.path());
assert!(
!log.contains("kill-session"),
"an attached session must not be killed: {log:?}"
);
let evs = events_of_kind(&paths, "cleanup.session_retained");
assert_eq!(evs.len(), 1, "the skip must be recorded once");
assert_eq!(evs[0]["data"]["session"], "headless");
}
#[test]
fn foreground_run_never_touches_tmux() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 0); let tmux = fake_tmux(tmp.path(), "");
cleanup_managed_session_with(&paths, &tmux);
assert!(
std::fs::read_to_string(tmp.path().join("tmux.log")).is_err(),
"tmux must not be invoked for a foreground run"
);
assert!(events_of_kind(&paths, "cleanup.session_killed").is_empty());
assert!(events_of_kind(&paths, "cleanup.session_retained").is_empty());
}
#[test]
fn already_gone_session_is_noop() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_headless(&paths, "headless");
let tmux = fake_tmux(tmp.path(), r#"case "$*" in *list-windows*) exit 1;; esac"#);
cleanup_managed_session_with(&paths, &tmux);
let log = tmux_log(tmp.path());
assert!(!log.contains("kill-session"), "log={log:?}");
assert!(events_of_kind(&paths, "cleanup.session_killed").is_empty());
assert!(events_of_kind(&paths, "cleanup.session_retained").is_empty());
}
#[test]
fn session_killed_event_is_idempotent() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_headless(&paths, "headless");
let tmux = fake_tmux(
tmp.path(),
r#"case "$*" in *list-windows*) printf '0\tzsh\n';; esac"#,
);
cleanup_managed_session_with(&paths, &tmux);
cleanup_managed_session_with(&paths, &tmux);
assert_eq!(events_of_kind(&paths, "cleanup.session_killed").len(), 1);
}
#[test]
fn managed_session_socket_threaded_into_tmux() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap_headless(&paths, "headless");
forge_node(
&paths,
"n-0001",
json!({
"tmux_session": "headless",
"tmux_window_id": "@5",
"tmux_socket": "/tmp/sock-7",
"worktree_path": "/fake/wt",
}),
);
let tmux = fake_tmux(
tmp.path(),
r#"case "$*" in *list-windows*) printf '0\tzsh\n';; esac"#,
);
cleanup_managed_session_with(&paths, &tmux);
let log = tmux_log(tmp.path());
assert!(
log.contains("-S /tmp/sock-7 list-windows"),
"socket must be threaded into list-windows: {log:?}"
);
assert!(
log.contains("-S /tmp/sock-7 kill-session -t headless"),
"socket must be threaded into kill-session: {log:?}"
);
}
#[test]
fn synthetic_default_window_classification() {
for n in ["zsh", "bash", "sh", "fish", "-zsh", " bash "] {
assert!(is_synthetic_default_window(n), "{n:?} should be default");
}
for n in ["🎬 wt/foo", "vim", "claude", "wt/abc"] {
assert!(!is_synthetic_default_window(n), "{n:?} is a real window");
}
}
#[test]
fn rollup_none_while_a_node_is_live() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 2);
report(&paths, "n-0001", json!({ "success": true }));
assert_eq!(rollup_status(&paths, true), None);
}
#[test]
fn rollup_done_when_all_nodes_succeed() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 2);
report(&paths, "n-0001", json!({ "success": true }));
report(&paths, "n-0002", json!({ "success": true }));
assert_eq!(rollup_status(&paths, true), Some(Status::Done));
}
#[test]
fn rollup_failed_when_any_node_fails() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 2);
report(&paths, "n-0001", json!({ "success": true }));
report(&paths, "n-0002", json!({ "success": false }));
assert_eq!(rollup_status(&paths, true), Some(Status::Failed));
}
#[test]
fn rollup_failed_when_a_node_is_cancelled() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 1);
report(
&paths,
"n-0001",
json!({ "success": false, "cancelled": true, "reason": "x" }),
);
assert_eq!(rollup_status(&paths, true), Some(Status::Failed));
}
#[test]
fn rollup_none_when_children_pending() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 1);
report(&paths, "n-0001", json!({ "success": true }));
assert_eq!(rollup_status(&paths, false), None);
assert_eq!(rollup_status(&paths, true), Some(Status::Done));
}
#[test]
fn rollup_none_with_no_nodes() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
append_and_apply_event(
&paths,
"run.created",
None,
None,
json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "t" }),
)
.unwrap();
assert_eq!(rollup_status(&paths, true), None);
}
#[test]
fn explicit_merge_marker_detected() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 1);
report(&paths, "n-0001", json!({ "success": true }));
assert!(!any_node_merged_explicitly(&paths));
}
#[test]
fn explicit_merge_marker_present() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 2);
report(&paths, "n-0001", json!({ "success": true }));
report(
&paths,
"n-0002",
json!({ "success": true, "via": "explicit-merge" }),
);
assert!(any_node_merged_explicitly(&paths));
}
#[test]
fn other_via_value_does_not_trigger_cleanup() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 1);
report(
&paths,
"n-0001",
json!({ "success": true, "via": "watchdog" }),
);
assert!(!any_node_merged_explicitly(&paths));
}
#[test]
fn rollup_none_when_already_terminal() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 1);
report(&paths, "n-0001", json!({ "success": true }));
append_and_apply_event(
&paths,
"run.status",
None,
None,
json!({ "status": "done" }),
)
.unwrap();
assert_eq!(rollup_status(&paths, true), None);
}
fn rev(repo: &std::path::Path, r: &str) -> String {
let out = Command::new("git")
.current_dir(repo)
.args(["rev-parse", r])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn ff_merge_into_main(repo: &std::path::Path, branch: &str) {
git(repo, &["merge", "--ff-only", branch]);
}
fn bootstrap_with_source(paths: &RunPaths, repo: &std::path::Path, source_branch: &str) {
append_and_apply_event(
paths,
"run.created",
None,
None,
json!({
"kind": "spinoff",
"lifecycle": "autonomous",
"title": "t",
"source_repo": repo.to_str().unwrap(),
"source_branch": source_branch,
}),
)
.unwrap();
}
#[test]
fn merged_branch_recognized_after_advancing_and_landing() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
let (repo, wt) = init_repo_with_worktree(&tmp);
let base = rev(&repo, "main"); bootstrap_with_source(&paths, &repo, "main");
commit_in_worktree(&wt, "fix.rs", "agent work");
ff_merge_into_main(&repo, "wt/foo");
let n = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo", "base_sha": base }),
);
assert!(
node_branch_merged_to_source(&paths, &n, &git_bin()),
"an advanced, landed branch must read as merged"
);
}
#[test]
fn unadvanced_branch_at_fork_point_is_not_merged() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
let (repo, wt) = init_repo_with_worktree(&tmp);
let base = rev(&repo, "main");
bootstrap_with_source(&paths, &repo, "main");
assert_eq!(commits_ahead(&repo, "main", "wt/foo"), 0);
let n = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo", "base_sha": base }),
);
assert!(
!node_branch_merged_to_source(&paths, &n, &git_bin()),
"a branch still at its fork point has merged nothing and must not reconcile"
);
}
#[test]
fn diverged_unmerged_branch_is_not_merged() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
let (repo, wt) = init_repo_with_worktree(&tmp);
let base = rev(&repo, "main");
bootstrap_with_source(&paths, &repo, "main");
commit_in_worktree(&wt, "wip.rs", "in progress"); assert_eq!(commits_ahead(&repo, "main", "wt/foo"), 1);
let n = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo", "base_sha": base }),
);
assert!(!node_branch_merged_to_source(&paths, &n, &git_bin()));
}
#[test]
fn missing_base_sha_declines_reconcile() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
let (repo, wt) = init_repo_with_worktree(&tmp);
bootstrap_with_source(&paths, &repo, "main");
commit_in_worktree(&wt, "fix.rs", "agent work");
ff_merge_into_main(&repo, "wt/foo");
let n = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo" }),
);
assert!(
!node_branch_merged_to_source(&paths, &n, &git_bin()),
"no base_sha → cannot confirm the branch advanced → decline"
);
}
#[test]
fn merge_reconciled_report_tears_down_like_explicit_merge() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
let (repo, wt) = init_repo_with_worktree(&tmp);
let base = rev(&repo, "main");
bootstrap_with_source(&paths, &repo, "main");
commit_in_worktree(&wt, "fix.rs", "agent work");
ff_merge_into_main(&repo, "wt/foo");
let _ = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo", "base_sha": base }),
);
report(
&paths,
"n-0001",
json!({ "success": true, "via": VIA_MERGE_RECONCILED }),
);
let n = read_node_opt(&paths, &nid("n-0001")).unwrap().unwrap();
cleanup_node(&paths, &n, "/usr/bin/true", &git_bin());
assert!(!wt.exists(), "reconciled merge must remove the worktree");
assert!(
!branch_exists(&repo, "wt/foo"),
"reconciled merge must delete the branch"
);
assert!(
events_of_kind(&paths, "cleanup.branch_preserved").is_empty(),
"a merged branch must NEVER be reported preserved"
);
}
#[test]
fn node_branch_merged_marker_classification() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 0);
let cases = [
(json!({ "success": true, "via": "explicit-merge" }), true),
(
json!({ "success": true, "via": VIA_MERGE_RECONCILED }),
true,
),
(json!({ "success": true }), false),
(json!({ "success": true, "via": "watchdog" }), false),
(
json!({ "success": false, "via": VIA_MERGE_RECONCILED }),
false,
),
];
for (i, (report_data, want)) in cases.into_iter().enumerate() {
let node = format!("n-2{i:03}");
let _ = forge_node(&paths, &node, json!({ "branch": "wt/x" }));
report(&paths, &node, report_data);
let n = read_node_opt(&paths, &nid(&node)).unwrap().unwrap();
assert_eq!(node_branch_merged(&n), want);
}
}
#[test]
fn dirty_worktree_declines_reconcile() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
let (repo, wt) = init_repo_with_worktree(&tmp);
let base = rev(&repo, "main");
bootstrap_with_source(&paths, &repo, "main");
commit_in_worktree(&wt, "fix.rs", "agent work");
ff_merge_into_main(&repo, "wt/foo"); std::fs::write(wt.join("more.rs"), "still editing").unwrap();
let n = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo", "base_sha": base }),
);
assert!(
!node_branch_merged_to_source(&paths, &n, &git_bin()),
"a merged branch with a dirty worktree must not reconcile — live work would be lost"
);
}
#[test]
fn rewound_branch_declines_reconcile() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
let (repo, wt) = init_repo_with_worktree(&tmp);
let base = rev(&repo, "main");
bootstrap_with_source(&paths, &repo, "main");
commit_in_worktree(&wt, "fix.rs", "agent work");
ff_merge_into_main(&repo, "wt/foo");
git(&wt, &["reset", "--hard", &base]);
assert_eq!(commits_ahead(&repo, "main", "wt/foo"), 0);
let n = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo", "base_sha": base }),
);
assert!(
!node_branch_merged_to_source(&paths, &n, &git_bin()),
"a branch rewound to its fork base advanced nothing and must not reconcile"
);
}
#[test]
fn merge_reconciled_preserves_a_since_diverged_branch() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
let (repo, wt) = init_repo_with_worktree(&tmp);
let base = rev(&repo, "main");
bootstrap_with_source(&paths, &repo, "main");
commit_in_worktree(&wt, "fix.rs", "agent work");
ff_merge_into_main(&repo, "wt/foo");
let _ = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo", "base_sha": base }),
);
report(
&paths,
"n-0001",
json!({ "success": true, "via": VIA_MERGE_RECONCILED }),
);
commit_in_worktree(&wt, "late.rs", "new unmerged work");
assert_eq!(commits_ahead(&repo, "main", "wt/foo"), 1);
let n = read_node_opt(&paths, &nid("n-0001")).unwrap().unwrap();
cleanup_node(&paths, &n, "/usr/bin/true", &git_bin());
assert!(
branch_exists(&repo, "wt/foo"),
"a since-diverged reconciled branch must be preserved, not force-deleted"
);
assert!(wt.exists(), "its worktree must be preserved too");
let evs = events_of_kind(&paths, "cleanup.branch_preserved");
assert_eq!(evs.len(), 1, "the preservation must be recorded");
}
#[test]
fn stranded_unmerged_work_is_recoverable() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
let (repo, wt) = init_repo_with_worktree(&tmp);
let base = rev(&repo, "main");
bootstrap_with_source(&paths, &repo, "main");
commit_in_worktree(&wt, "impl.rs", "green implementation");
assert_eq!(commits_ahead(&repo, "main", "wt/foo"), 1);
let n = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo", "base_sha": base }),
);
let r = node_recoverability(&paths, &n, &git_bin())
.expect("stranded unmerged work must produce a signal");
assert_eq!(r.unmerged_commits, 1);
assert!(
r.merges_cleanly,
"an untouched source fast-forwards cleanly"
);
assert!(r.recoverable());
assert_eq!(r.branch, "wt/foo");
assert_eq!(r.worktree_path.as_deref(), wt.to_str());
}
#[test]
fn stranded_work_recoverable_when_source_advanced_but_no_conflict() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
let (repo, wt) = init_repo_with_worktree(&tmp);
let base = rev(&repo, "main");
bootstrap_with_source(&paths, &repo, "main");
commit_in_worktree(&wt, "impl.rs", "agent work");
std::fs::write(repo.join("other.rs"), "unrelated").unwrap();
git(&repo, &["add", "-A"]);
git(&repo, &["commit", "-qm", "concurrent source work"]);
assert_eq!(commits_ahead(&repo, "main", "wt/foo"), 1);
let n = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo", "base_sha": base }),
);
let r = node_recoverability(&paths, &n, &git_bin()).expect("unmerged work → signal");
assert_eq!(r.unmerged_commits, 1);
assert!(
r.merges_cleanly,
"disjoint concurrent edits merge cleanly via three-way merge-tree"
);
assert!(r.recoverable());
}
#[test]
fn conflicting_unmerged_work_is_flagged_not_recoverable() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
let (repo, wt) = init_repo_with_worktree(&tmp);
let base = rev(&repo, "main");
bootstrap_with_source(&paths, &repo, "main");
std::fs::write(wt.join("README"), "agent version\n").unwrap();
git(&wt, &["add", "-A"]);
git(&wt, &["commit", "-qm", "agent edits README"]);
std::fs::write(repo.join("README"), "source version\n").unwrap();
git(&repo, &["add", "-A"]);
git(&repo, &["commit", "-qm", "source edits README"]);
assert_eq!(commits_ahead(&repo, "main", "wt/foo"), 1);
let n = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo", "base_sha": base }),
);
let r = node_recoverability(&paths, &n, &git_bin())
.expect("conflicting-but-unmerged work is still surfaced");
assert_eq!(r.unmerged_commits, 1);
assert!(!r.merges_cleanly, "divergent edits to one file conflict");
assert!(
!r.recoverable(),
"a conflicting branch is not auto-recoverable"
);
}
#[test]
fn empty_handed_death_produces_no_signal() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
let (repo, wt) = init_repo_with_worktree(&tmp);
let base = rev(&repo, "main");
bootstrap_with_source(&paths, &repo, "main");
assert_eq!(commits_ahead(&repo, "main", "wt/foo"), 0);
let n = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo", "base_sha": base }),
);
assert!(
node_recoverability(&paths, &n, &git_bin()).is_none(),
"no unmerged commits → no recoverability signal"
);
}
#[test]
fn missing_source_branch_declines_signal() {
let tmp = TempDir::new().unwrap();
let paths = fresh_run(&tmp);
bootstrap(&paths, 0); let (_repo, wt) = init_repo_with_worktree(&tmp);
commit_in_worktree(&wt, "impl.rs", "work");
let n = forge_node(
&paths,
"n-0001",
json!({ "worktree_path": wt.to_str().unwrap(), "branch": "wt/foo" }),
);
assert!(
node_recoverability(&paths, &n, &git_bin()).is_none(),
"no source_branch → cannot compute ahead-of-source → decline"
);
}
#[test]
fn recoverability_to_report_value_shape() {
let r = Recoverability {
unmerged_commits: 2,
merges_cleanly: true,
branch: "wt/foo".to_string(),
worktree_path: Some("/tmp/wt".to_string()),
};
assert_eq!(
r.to_report_value(),
json!({
"recoverable": true,
"unmerged_commits": 2,
"merges_cleanly": true,
"branch": "wt/foo",
"worktree_path": "/tmp/wt",
})
);
}
}