use std::collections::HashMap;
use std::path::Path;
use super::worktree_claude_registry::ClaudeRegistry;
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>>,
history: LinkHistory,
tmux: HashMap<String, String>,
claude: ClaudeRegistry,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) enum LinkHistory {
#[default]
Absent,
Read(HashMap<String, Vec<String>>),
Unreadable(String),
}
impl LinkHistory {
pub(crate) fn read(framework_root: &Path) -> Self {
match crate::core::session_links::link_history(framework_root) {
Ok(map) if map.is_empty() => Self::Absent,
Ok(map) => Self::Read(map),
Err(why) => Self::Unreadable(why),
}
}
}
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 with_history(mut self, history: LinkHistory) -> Self {
self.history = history;
self
}
pub(crate) fn with_tmux(mut self, pairs: impl IntoIterator<Item = (String, String)>) -> Self {
self.tmux.extend(pairs);
self
}
pub(crate) fn with_claude(mut self, claude: ClaudeRegistry) -> Self {
self.claude = claude;
self
}
pub(crate) fn session_end(&self, id: &str) -> SessionEnd {
if self.caller.as_deref() == Some(id) {
return SessionEnd::Caller;
}
if let Some(end) = self.aliases.get(id).and_then(|records| {
records
.iter()
.map(|m| self.alias_end(id, m))
.max_by_key(strictness)
}) {
return end;
}
match self.record_end(id) {
SessionEnd::Undeterminable(why) if self.stored(id) == Some(false) => {
let end = self
.superseded_end(id)
.unwrap_or(SessionEnd::Undeterminable(why));
self.registry_end(id, end)
}
end => end,
}
}
fn registry_usable(&self) -> bool {
self.by_id.is_some() && !matches!(self.history, LinkHistory::Unreadable(_))
}
fn registry_end(&self, id: &str, end: SessionEnd) -> SessionEnd {
if !self.registry_usable() {
return end;
}
match (end, self.claude.session_end(id)) {
(_, Some(SessionEnd::Live)) => SessionEnd::Live,
(SessionEnd::Undeterminable(_), Some(SessionEnd::Ended)) => SessionEnd::Ended,
(SessionEnd::Undeterminable(why), Some(SessionEnd::Undeterminable(more))) => {
SessionEnd::Undeterminable(format!("{why}; {more}"))
}
(end, _) => end,
}
}
fn alias_end(&self, id: &str, managed: &str) -> SessionEnd {
let end = self.record_end(managed);
let replaced = || {
self.tmux
.get(managed)
.is_some_and(|t| self.claude.replaced_in(id, t))
};
if end == SessionEnd::Live && self.registry_usable() && replaced() {
return SessionEnd::Ended;
}
end
}
fn stored(&self, managed: &str) -> Option<bool> {
self.by_id.as_ref().map(|by_id| by_id.contains_key(managed))
}
fn superseded_end(&self, id: &str) -> Option<SessionEnd> {
let map = match &self.history {
LinkHistory::Absent => return None,
LinkHistory::Unreadable(why) => {
return Some(SessionEnd::Undeterminable(format!(
"the session-link history could not be read ({why}), so whether a record \
once carried it is unknown"
)));
}
LinkHistory::Read(map) => map,
};
let records = map.get(id)?;
if let Some(missing) = records.iter().find(|m| self.stored(m) != Some(true)) {
return Some(SessionEnd::Undeterminable(format!(
"the session-link history links it to session {missing}, which no stored record \
names"
)));
}
Some(SessionEnd::Ended)
}
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,
}
}