use std::path::{Path, PathBuf};
use chrono::Utc;
use tempfile::TempDir;
use crate::core::session_links::record_link;
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};
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,
};
const PR: u64 = 7771;
pub(super) struct Scene {
pub(super) fx: GitWorktreeFixture,
pub(super) wt: PathBuf,
pub(super) branch: String,
pub(super) dispatcher: String,
}
pub(super) fn scene(name: &str) -> Scene {
let fx = GitWorktreeFixture::new();
let wt = fx.add_worktree_at(&fx.repo.join(".claude").join("worktrees"), name);
std::fs::write(wt.join("landed.rs"), "// landed\n").expect("write landed file");
GitWorktreeFixture::commit_all_and_push(&wt, "landed");
let owner = GitWorktreeFixture::stamp_agent_sentinel(&wt, &format!("a{name}"));
Scene {
branch: format!("wt/{name}"),
dispatcher: owner.parent_session_id.0.to_string(),
fx,
wt,
}
}
pub(super) fn pm_record(tmux_name: &str, current: &str) -> SessionRecord {
SessionRecord {
id: ManagedSessionId::for_adopted_tmux_name(tmux_name),
tmux_name: tmux_name.to_owned(),
cwd: PathBuf::new(),
task: "the PM that dispatched the agent".into(),
state: ManagedSessionState::Deleted,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: None,
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: Some(current.to_owned()),
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(),
}
}
enum Links {
Superseded,
Current,
SupersededUnreadable,
LinkedToAnUnstoredSession,
}
async fn claims(scene: &Scene, links: Links) -> LiveClaims {
let root = TempDir::new().expect("framework root");
let data_dir = root.path().join("session-manager");
std::fs::create_dir_all(&data_dir).expect("session-manager dir");
let tmux_name = "tm-pm-7771";
let fake = FakeTmuxDriver::new();
fake.seeded_names
.lock()
.expect("lock")
.push(tmux_name.to_string());
let mgr = SessionManager::new(&data_dir, fake).await.expect("manager");
let managed = ManagedSessionId::for_adopted_tmux_name(tmux_name).to_string();
let newer = "0b7e1a55-7771-4000-8000-000000000001";
let current = match links {
Links::Current => scene.dispatcher.as_str(),
_ => newer,
};
mgr.store
.write()
.await
.upsert(pm_record(tmux_name, current))
.await
.expect("upsert");
let linked_to = match links {
Links::LinkedToAnUnstoredSession => "tm-compacted-7771",
_ => managed.as_str(),
};
record_link(root.path(), linked_to, &scene.dispatcher);
record_link(root.path(), &managed, current);
if matches!(links, Links::SupersededUnreadable) {
let dir = root.path().join("usage").join("session-links");
std::fs::create_dir(dir.join("tm-unreadable-7771")).expect("an unreadable entry");
}
mgr.workspace_claims(Some("tm-caller-7771".to_string()))
.await
}
pub(super) fn agent_live(_: &AgentWorktreeOwner) -> AgentDelegationState {
AgentDelegationState::Live
}
fn no_agents(_: &AgentWorktreeOwner) -> AgentDelegationState {
AgentDelegationState::Unknown
}
pub(super) fn merged_index(branch: &str) -> PrIndex {
PrIndex::from_json(
&format!(r#"[{{"number": {PR}, "headRefName": "{branch}", "state": "MERGED"}}]"#),
400,
)
}
pub(super) fn verdict(
scene: &Scene,
claims: &LiveClaims,
agent_state: &dyn Fn(&AgentWorktreeOwner) -> AgentDelegationState,
) -> ReclaimVerdict {
let branch = scene.branch.clone();
survey_with_index(
&scene.fx.repos_root,
claims,
&|_: &Path| merged_index(&branch),
agent_state,
SurveyBudget::default(),
false,
&KeepList::default(),
&[],
)
.candidates
.into_iter()
.find(|c| c.path == scene.wt)
.unwrap_or_else(|| panic!("the survey must list {}", scene.wt.display()))
.verdict
}
pub(super) fn refused(v: &ReclaimVerdict) -> String {
match v {
ReclaimVerdict::Blocked { reason, .. } | ReclaimVerdict::BlockedByAgent { reason, .. } => {
reason.clone()
}
_ => panic!("the tree must be kept: {v:?}"),
}
}
#[tokio::test]
async fn worktree_7771_a_superseded_sessions_open_delegation_tree_is_reclaimed() {
let s = scene("agent-superseded-7771");
let c = claims(&s, Links::Superseded).await;
assert_eq!(
verdict(&s, &c, &agent_live),
ReclaimVerdict::Reclaimable { pr: PR }
);
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: &agent_live,
in_use_now: &|| Some(c.clone()),
index_for: &|_: &Path| merged_index(&branch),
},
ReclaimMode::Remove,
&[],
&crate::session_manager::worktree_scope::WorktreeScope::all(),
);
assert_eq!(out.removed.len(), 1, "the delete loop reclaims it: {out:?}");
assert!(!s.wt.exists(), "and the directory is gone");
}
#[tokio::test]
async fn worktree_7771_the_current_sessions_open_delegation_keeps_the_tree() {
let s = scene("agent-current-7771");
let c = claims(&s, Links::Current).await;
let v = verdict(&s, &c, &agent_live);
assert!(
matches!(v, ReclaimVerdict::BlockedByAgent { .. }),
"gate 4 keeps it: {v:?}"
);
assert!(refused(&v).contains("has not ended"), "{v:?}");
}
#[tokio::test]
async fn worktree_7771_an_unreadable_link_history_keeps_the_tree() {
let s = scene("agent-unreadable-7771");
let c = claims(&s, Links::SupersededUnreadable).await;
let reason = refused(&verdict(&s, &c, &agent_live));
assert!(reason.contains("has not ended"), "{reason}");
let reason = refused(&verdict(&s, &c, &no_agents));
assert!(reason.contains(&s.dispatcher), "{reason}");
assert!(reason.contains("could not be read"), "{reason}");
}
#[tokio::test]
async fn worktree_7771_a_history_naming_an_unstored_session_keeps_the_tree() {
let s = scene("agent-unstored-7771");
let c = claims(&s, Links::LinkedToAnUnstoredSession).await;
let reason = refused(&verdict(&s, &c, &agent_live));
assert!(reason.contains("has not ended"), "{reason}");
let reason = refused(&verdict(&s, &c, &no_agents));
assert!(reason.contains(&s.dispatcher), "{reason}");
assert!(reason.contains("tm-compacted-7771"), "{reason}");
}