use std::collections::HashMap;
use std::path::Path;
use super::worktree_owner_gate::{OwnerGate, SessionEnd, owner_refusal};
use super::worktree_ownership::{
AgentDelegationState, AgentWorktreeOwner, SentinelOwner, is_harness_agent_worktree,
read_sentinel_owner,
};
use super::worktree_reclaim_claim::{ClaimLiveness, ClaimState};
use super::worktree_registry::{HarnessLockState, harness_lock_state};
pub(crate) type AgentStateProbe<'a> = &'a dyn Fn(&AgentWorktreeOwner) -> AgentDelegationState;
pub(crate) fn agent_ownership_blocks(
path: &Path,
agent_state: AgentStateProbe<'_>,
owners: &SessionOwners,
) -> Option<String> {
if is_harness_agent_worktree(path) {
let session_end = |id: &str| owners.session_end(id);
return owner_refusal(path, &OwnerGate::host(agent_state, &session_end));
}
match read_sentinel_owner(path) {
SentinelOwner::Agent(owner, _) => match agent_state(&owner) {
AgentDelegationState::Live => Some(format!(
"owned by dispatched agent {} — a delegation naming it has not ended, so it is \
still working in this tree (#5661)",
owner.agent_id
)),
AgentDelegationState::Unknown => match harness_lock_state(path) {
HarnessLockState::Released => None,
HarnessLockState::Held => Some(format!(
"owned by dispatched agent {} and git still reports the harness's \
agent-lifetime lock on this worktree (#6561)",
owner.agent_id
)),
HarnessLockState::Undeterminable => Some(format!(
"owned by dispatched agent {} — the delegation registry holds no record of \
that agent, and git could not be asked whether the harness still holds the \
worktree. Two silences are not an answer: undeterminable, not absent \
(#5661, #6561, ADR-0045)",
owner.agent_id
)),
},
AgentDelegationState::Ended => None,
},
SentinelOwner::Known(..) | SentinelOwner::Unknown => None,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SessionOwners {
by_id: Option<HashMap<String, ClaimLiveness>>,
caller: Option<String>,
aliases: HashMap<String, Vec<String>>,
}
impl SessionOwners {
pub(crate) fn with_caller(mut self, caller: Option<String>) -> Self {
self.caller = caller;
self
}
pub(crate) fn with_aliases(
mut self,
pairs: impl IntoIterator<Item = (String, String)>,
) -> Self {
for (claude, managed) in pairs {
self.aliases.entry(claude).or_default().push(managed);
}
self
}
pub(crate) fn session_end(&self, id: &str) -> SessionEnd {
if self.caller.as_deref() == Some(id) {
return SessionEnd::Caller;
}
self.aliases
.get(id)
.and_then(|records| {
records
.iter()
.map(|m| self.record_end(m))
.max_by_key(strictness)
})
.unwrap_or_else(|| self.record_end(id))
}
fn record_end(&self, managed: &str) -> SessionEnd {
if self.caller.as_deref() == Some(managed) {
return SessionEnd::Caller;
}
let Some(by_id) = &self.by_id else {
return SessionEnd::Undeterminable("the session store was not read".into());
};
match by_id.get(managed) {
Some(ClaimLiveness::SessionGone) => SessionEnd::Ended,
Some(ClaimLiveness::Live) => SessionEnd::Live,
None => SessionEnd::Undeterminable("no stored session record names it".into()),
}
}
pub(crate) fn observed(records: impl IntoIterator<Item = (String, ClaimLiveness)>) -> Self {
let mut by_id: HashMap<String, ClaimLiveness> = HashMap::new();
for (id, liveness) in records {
let entry = by_id.entry(id).or_insert(liveness);
if liveness == ClaimLiveness::Live {
*entry = ClaimLiveness::Live;
}
}
Self {
by_id: Some(by_id),
..Self::default()
}
}
}
fn strictness(end: &SessionEnd) -> u8 {
match end {
SessionEnd::Ended => 0,
SessionEnd::Caller => 1,
SessionEnd::Undeterminable(_) => 2,
SessionEnd::Live => 3,
}
}
pub(crate) fn session_ownership_blocks(path: &Path, owners: &SessionOwners) -> Option<String> {
if is_harness_agent_worktree(path) {
return None;
}
let SentinelOwner::Known(owner, _) = read_sentinel_owner(path) else {
return None;
};
if owners.session_end(&owner.to_string()) == SessionEnd::Caller {
return None;
}
let Some(by_id) = &owners.by_id else {
return Some(format!(
"owned by session {owner}, and the session store was not read, so nothing can show \
that session has ended (#7652, ADR-0045)"
));
};
match by_id.get(&owner.to_string()) {
Some(ClaimLiveness::SessionGone) => None,
Some(ClaimLiveness::Live) => Some(format!(
"owned by session {owner}, which tmux still lists, or whose liveness the tmux probe \
could not establish (#7652)"
)),
None => Some(format!(
"owned by session {owner}, and no stored session record names it, so nothing can \
show that session has ended — unrecorded is not dead. If that session's record was \
compacted by `tm sessions prune`, no later run will recover it: confirm nobody is \
working in this tree and remove it yourself with `git worktree remove` (#7652, \
ADR-0045)"
)),
}
}
pub(crate) fn unattributed_nested_blocks(path: &Path, claim: &ClaimState) -> Option<String> {
let ClaimState::ForeignNested { session, .. } = claim else {
return None;
};
if is_harness_agent_worktree(path) {
return None;
}
match read_sentinel_owner(path) {
SentinelOwner::Unknown => Some(format!(
"session {session} claims a workspace containing this worktree, and the ownership \
sentinel is absent, empty, malformed or unreadable, so nothing attributes the tree \
to anyone else — the live claim stands (#7652, ADR-0045)"
)),
SentinelOwner::Known(..) | SentinelOwner::Agent(..) => None,
}
}