use std::cell::Cell;
use std::path::{Path, PathBuf};
use chrono::Utc;
use tempfile::TempDir;
use crate::session_manager::decommission::WORKTREE_SENTINEL_FILE;
use crate::session_manager::manager::SessionManager;
use crate::session_manager::record::{ManagedSessionId, ManagedSessionState, SessionRecord};
use crate::session_manager::tests::FakeTmuxDriver;
use crate::session_manager::worktree_git_fixture::GitWorktreeFixture;
use crate::session_manager::worktree_ownership::{
AgentDelegationState, AgentWorktreeOwner, sentinel_payload_bytes,
};
use crate::session_manager::worktree_reclaim::{
KeepList, LiveClaims, PrIndex, ReclaimMode, ReclaimVerdict,
};
use crate::session_manager::worktree_reclaim_sweep::{
FreshProbes, SurveyBudget, reclaim_scoped, survey_with_index,
};
#[derive(Clone, Copy)]
enum OwnerRecord {
ProjectRoot,
NoWorkspace,
Absent,
}
#[derive(Clone, Copy)]
enum Tmux {
ListsOwner,
OwnerGone,
Errors,
}
struct Scene {
fx: GitWorktreeFixture,
wt: PathBuf,
branch: String,
owner: ManagedSessionId,
}
fn record(tmux_name: &str, workspace: Option<&Path>) -> SessionRecord {
SessionRecord {
id: ManagedSessionId::for_adopted_tmux_name(tmux_name),
tmux_name: tmux_name.to_owned(),
cwd: workspace.map(Path::to_path_buf).unwrap_or_default(),
task: "owns a nested worktree".into(),
state: ManagedSessionState::Deleted,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: workspace.map(Path::to_path_buf),
repo_url: None,
branch: None,
pending_decision: None,
proposed_default: None,
correlation: Default::default(),
runtime: Default::default(),
ephemeral: false,
workspace_owned: false,
source_id: None,
claude_session_id: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
tmux_server: None,
injection_status: Default::default(),
worktree_owner: None,
terminal_at: None,
stop_cause: None,
kind: Default::default(),
}
}
fn scene(name: &str) -> Scene {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree(name);
std::fs::write(wt.join("landed.rs"), "// landed\n").expect("write landed file");
GitWorktreeFixture::commit_all_and_push(&wt, "landed");
let owner = ManagedSessionId::for_adopted_tmux_name(&format!("tm-{name}"));
std::fs::write(
wt.join(WORKTREE_SENTINEL_FILE),
sentinel_payload_bytes(owner),
)
.expect("write the owner's sentinel");
Scene {
branch: format!("session/{name}"),
fx,
wt,
owner,
}
}
async fn claims(scene: &Scene, name: &str, rec: OwnerRecord, tmux: Tmux) -> LiveClaims {
let tmux_name = format!("tm-{name}");
let dir = TempDir::new().expect("tempdir");
let fake = FakeTmuxDriver::new();
if matches!(tmux, Tmux::ListsOwner) {
fake.seeded_names
.lock()
.expect("lock")
.push(tmux_name.clone());
}
let mgr = SessionManager::new(dir.path(), fake.clone())
.await
.expect("manager");
let workspace = match rec {
OwnerRecord::ProjectRoot => Some(scene.fx.repo.as_path()),
OwnerRecord::NoWorkspace | OwnerRecord::Absent => None,
};
if !matches!(rec, OwnerRecord::Absent) {
let mut store = mgr.store.write().await;
store
.upsert(record(&tmux_name, workspace))
.await
.expect("upsert");
}
if matches!(tmux, Tmux::Errors) {
*fake.list_sessions_should_fail.lock().expect("lock") = true;
}
mgr.workspace_claims(Some("tm-caller-7652".to_string()))
.await
}
fn no_agents(_: &AgentWorktreeOwner) -> AgentDelegationState {
AgentDelegationState::Unknown
}
fn merged_index(branch: &str) -> PrIndex {
PrIndex::from_json(
&format!(r#"[{{"number": 7652, "headRefName": "{branch}", "state": "MERGED"}}]"#),
400,
)
}
fn verdict(scene: &Scene, claims: &LiveClaims) -> ReclaimVerdict {
let branch = scene.branch.clone();
let survey = survey_with_index(
&scene.fx.repos_root,
claims,
&|_: &Path| merged_index(&branch),
&no_agents,
SurveyBudget::default(),
false,
&KeepList::default(),
&[],
);
survey
.candidates
.into_iter()
.find(|c| c.path == scene.wt)
.unwrap_or_else(|| panic!("the survey must list {}", scene.wt.display()))
.verdict
}
fn refused(v: &ReclaimVerdict, owner: &ManagedSessionId) -> String {
let reason = match v {
ReclaimVerdict::Blocked { reason, .. } | ReclaimVerdict::BlockedByAgent { reason, .. } => {
reason.clone()
}
ReclaimVerdict::Reclaimable { .. } | ReclaimVerdict::ReclaimableLandedContent { .. } => {
panic!("a session-owned tree whose owner is not proven gone was reclaimable: {v:?}")
}
};
assert!(
reason.contains(&owner.to_string()),
"names the owner: {reason}"
);
assert!(reason.contains("#7652"), "{reason}");
reason
}
#[tokio::test]
async fn worktree_7652_a_live_owners_nested_worktree_is_refused() {
let s = scene("owner-live-7652");
let c = claims(
&s,
"owner-live-7652",
OwnerRecord::ProjectRoot,
Tmux::ListsOwner,
)
.await;
assert!(
c.claim_state(&s.wt).refusal(false).is_none(),
"premise: gate 2 permits a project-root claim over a nested worktree"
);
let reason = refused(&verdict(&s, &c), &s.owner);
assert!(reason.contains("tmux still lists"), "{reason}");
}
#[tokio::test]
async fn worktree_7652_a_dead_owners_nested_worktree_is_reclaimed() {
let s = scene("owner-dead-7652");
let c = claims(
&s,
"owner-dead-7652",
OwnerRecord::ProjectRoot,
Tmux::OwnerGone,
)
.await;
assert_eq!(verdict(&s, &c), ReclaimVerdict::Reclaimable { pr: 7652 });
}
#[tokio::test]
async fn worktree_7652_an_owner_with_no_record_is_refused() {
let s = scene("owner-unrecorded-7652");
let c = claims(
&s,
"owner-unrecorded-7652",
OwnerRecord::Absent,
Tmux::OwnerGone,
)
.await;
let reason = refused(&verdict(&s, &c), &s.owner);
assert!(reason.contains("no stored session record"), "{reason}");
assert!(reason.contains("git worktree remove"), "{reason}");
assert!(reason.contains("tm sessions prune"), "{reason}");
}
#[tokio::test]
async fn worktree_7652_owners_include_a_record_with_no_workspace_path() {
let live = scene("owner-noworkspace-live-7652");
let c = claims(
&live,
"owner-noworkspace-live-7652",
OwnerRecord::NoWorkspace,
Tmux::ListsOwner,
)
.await;
refused(&verdict(&live, &c), &live.owner);
let dead = scene("owner-noworkspace-dead-7652");
let c = claims(
&dead,
"owner-noworkspace-dead-7652",
OwnerRecord::NoWorkspace,
Tmux::OwnerGone,
)
.await;
assert_eq!(verdict(&dead, &c), ReclaimVerdict::Reclaimable { pr: 7652 });
}
#[tokio::test]
async fn worktree_7652_an_errored_tmux_probe_is_refused() {
let s = scene("owner-tmux-error-7652");
let c = claims(
&s,
"owner-tmux-error-7652",
OwnerRecord::ProjectRoot,
Tmux::Errors,
)
.await;
refused(&verdict(&s, &c), &s.owner);
}
#[test]
fn worktree_7652_an_unanswerable_claim_probe_reclaims_nothing() {
let s = scene("owner-store-unreadable-7652");
let branch = s.branch.clone();
let out = reclaim_scoped(
&s.fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &KeepList::default,
agent_state: &no_agents,
in_use_now: &|| None,
index_for: &|_: &Path| merged_index(&branch),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert!(out.removed.is_empty(), "{out:?}");
assert!(s.wt.exists(), "the tree must still be on disk");
let candidate = out
.survey
.candidates
.iter()
.find(|c| c.path == s.wt)
.expect("listed");
refused(&candidate.verdict, &s.owner);
}
#[tokio::test]
async fn worktree_7652_the_recheck_refuses_an_owner_that_came_back() {
let s = scene("owner-came-back-7652");
let name = "owner-came-back-7652";
let gone = claims(&s, name, OwnerRecord::ProjectRoot, Tmux::OwnerGone).await;
let back = claims(&s, name, OwnerRecord::ProjectRoot, Tmux::ListsOwner).await;
let reads = Cell::new(0usize);
let branch = s.branch.clone();
let out = reclaim_scoped(
&s.fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &KeepList::default,
agent_state: &no_agents,
in_use_now: &|| {
reads.set(reads.get() + 1);
Some(if reads.get() == 1 {
gone.clone()
} else {
back.clone()
})
},
index_for: &|_: &Path| merged_index(&branch),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert!(out.removed.is_empty(), "{out:?}");
assert!(s.wt.exists(), "the returning owner's tree must survive");
assert!(
out.refused_at_recheck
.iter()
.any(|r| r.contains("#7652") && r.contains(&s.owner.to_string())),
"{:?}",
out.refused_at_recheck
);
}
#[tokio::test]
async fn worktree_7652_an_owner_back_after_the_dirt_check_is_refused() {
let s = scene("owner-back-late-7652");
let name = "owner-back-late-7652";
let gone = claims(&s, name, OwnerRecord::ProjectRoot, Tmux::OwnerGone).await;
let back = claims(&s, name, OwnerRecord::ProjectRoot, Tmux::ListsOwner).await;
let reads = Cell::new(0usize);
let branch = s.branch.clone();
let out = reclaim_scoped(
&s.fx.repos_root,
&FreshProbes {
prove: &crate::session_manager::worktree_reclaim_landed::reclaim_landed_proof,
launched_from: &[],
cwd_holder: &|_| None,
keep_list: &KeepList::default,
agent_state: &no_agents,
in_use_now: &|| {
reads.set(reads.get() + 1);
Some(if reads.get() <= 2 {
gone.clone()
} else {
back.clone()
})
},
index_for: &|_: &Path| merged_index(&branch),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert!(out.removed.is_empty(), "{out:?}");
assert!(s.wt.exists(), "the returning owner's tree must survive");
assert!(
out.refused_at_recheck
.iter()
.any(|r| r.contains("#7652") && r.contains(&s.owner.to_string())),
"{:?}",
out.refused_at_recheck
);
}