use std::path::Path;
use super::worktree_ownership::{
AgentDelegationState, AgentWorktreeOwner, OwnerReadError, SentinelOwner,
};
use super::worktree_registry::{
harness_lock_pid_in_reason, is_harness_agent_lock_reason, list_registered_worktrees,
pid_liveness,
};
const START_TOLERANCE_SECS: i64 = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum LockLiveness {
Unlocked,
Stale(String),
Held(String),
Refused(String),
}
impl LockLiveness {
pub(crate) fn refusal(&self) -> Option<String> {
match self {
Self::Unlocked | Self::Stale(_) => None,
Self::Held(why) | Self::Refused(why) => Some(why.clone()),
}
}
}
pub(crate) fn parse_lock_start(reason: &str) -> Option<i64> {
let rest = reason.split_once(" start ")?.1;
let stamp = rest.split_once(')').map_or(rest, |(s, _)| s);
let normal = stamp
.split_whitespace()
.skip(1)
.collect::<Vec<_>>()
.join(" ");
chrono::NaiveDateTime::parse_from_str(&normal, "%b %d %H:%M:%S %Y")
.ok()
.map(|t| t.and_utc().timestamp())
}
pub(crate) fn judge_lock(
reason: &str,
pid_alive: &dyn Fn(u32) -> Option<bool>,
start_of: &dyn Fn(u32) -> Result<i64, String>,
) -> LockLiveness {
if !is_harness_agent_lock_reason(reason) {
return LockLiveness::Refused(format!(
"git-locked by the operator (`{reason}`) — a standing veto no reclaim overrides"
));
}
let Some(pid) = harness_lock_pid_in_reason(reason) else {
return LockLiveness::Refused(format!(
"the harness lock `{reason}` names no pid this can read, so its holder cannot be \
judged (#7771, ADR-0045)"
));
};
match pid_alive(pid) {
Some(false) => return LockLiveness::Stale(format!("lock pid {pid} is gone")),
None => {
return LockLiveness::Refused(format!(
"whether lock pid {pid} runs could not be read from the process table \
(#7771, ADR-0045)"
));
}
Some(true) => {}
}
let Some(recorded) = parse_lock_start(reason) else {
return LockLiveness::Refused(format!(
"lock pid {pid} runs and the lock records no readable start time, so a reused pid \
cannot be told from the holder (#7771, ADR-0045)"
));
};
match start_of(pid) {
Ok(actual) if (actual - recorded).abs() <= START_TOLERANCE_SECS => {
LockLiveness::Held(format!(
"git still reports the harness's agent-lifetime lock (`{reason}`), and pid {pid} \
runs with the start time it recorded (#6561, #7771)"
))
}
Ok(actual) => LockLiveness::Stale(format!(
"lock pid {pid} was reused (lock start {recorded}, process start {actual})"
)),
Err(e) => LockLiveness::Refused(format!(
"lock pid {pid} runs and its start time could not be read: {e} (#7771, ADR-0045)"
)),
}
}
#[cfg(test)]
pub(crate) fn format_lock_start(secs: i64) -> String {
chrono::DateTime::from_timestamp(secs, 0)
.map(|t| t.format("%a %b %e %H:%M:%S %Y").to_string())
.unwrap_or_default()
}
pub(crate) fn process_start_secs(pid: u32) -> Result<i64, String> {
use sysinfo::{Pid, ProcessesToUpdate, System};
let pid = Pid::from_u32(pid);
let mut sys = System::new();
sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
let start = sys
.process(pid)
.ok_or_else(|| "the process table holds no entry for it".to_string())?
.start_time();
i64::try_from(start).map_err(|e| format!("start time out of range: {e}"))
}
pub(crate) fn lock_liveness(path: &Path) -> LockLiveness {
let Some(registered) = list_registered_worktrees(path) else {
return LockLiveness::Refused(
"git could not be asked whether this tree is locked (#7771, ADR-0045)".into(),
);
};
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let found = registered
.into_iter()
.find(|w| std::fs::canonicalize(&w.path).unwrap_or_else(|_| w.path.clone()) == canonical);
let Some(record) = found else {
return LockLiveness::Refused("git does not list this path as a worktree".into());
};
if !record.locked {
return LockLiveness::Unlocked;
}
let reason = record.lock_reason.unwrap_or_default();
judge_lock(&reason, &pid_liveness, &process_start_secs)
}
pub(crate) fn release_stale_lock(path: &Path) -> Result<(), String> {
match lock_liveness(path) {
LockLiveness::Unlocked => Ok(()),
LockLiveness::Stale(why) => {
let out = super::worktree_safety::git_command(path, &["worktree", "unlock", "."])
.output()
.map_err(|e| format!("`git worktree unlock` could not run ({why}): {e}"))?;
if out.status.success() {
tracing::info!(path = %path.display(), "released a stale harness lock — {why} (#7771)");
return Ok(());
}
Err(format!(
"`git worktree unlock` failed ({why}): {}",
String::from_utf8_lossy(&out.stderr).trim()
))
}
LockLiveness::Held(why) | LockLiveness::Refused(why) => Err(why),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SessionEnd {
Caller,
Ended,
Live,
Undeterminable(String),
}
pub(crate) fn read_owner(path: &Path) -> Result<Option<SentinelOwner>, String> {
match super::worktree_ownership::read_sentinel_owner_strict(path) {
Ok(None | Some(SentinelOwner::Unknown)) => Ok(None),
Ok(owner) => Ok(owner),
Err(OwnerReadError::Unreadable { path, reason }) => Err(format!(
"its owner file {} cannot be read ({reason}), so who owns the tree cannot be \
determined (#7771, #8511, ADR-0045)",
path.display()
)),
Err(OwnerReadError::Corrupt { path, reason }) => Err(format!(
"its owner file {} does not parse ({reason}), so who owns the tree cannot be \
determined (#7771, #8511, ADR-0045)",
path.display()
)),
}
}
pub(crate) struct OwnerGate<'a> {
pub agent_state: &'a dyn Fn(&AgentWorktreeOwner) -> AgentDelegationState,
pub session_end: &'a dyn Fn(&str) -> SessionEnd,
pub lock: &'a dyn Fn(&Path) -> LockLiveness,
pub cwd_holder: &'a dyn Fn(&Path) -> Option<String>,
}
impl OwnerGate<'_> {
pub(crate) fn host<'a>(
agent_state: &'a dyn Fn(&AgentWorktreeOwner) -> AgentDelegationState,
session_end: &'a dyn Fn(&str) -> SessionEnd,
) -> OwnerGate<'a> {
OwnerGate {
agent_state,
session_end,
lock: &lock_liveness,
cwd_holder: &super::worktree_liveness::process_holding,
}
}
}
pub(crate) fn owner_refusal(path: &Path, gate: &OwnerGate<'_>) -> Option<String> {
if let Some(why) = (gate.lock)(path).refusal() {
return Some(why);
}
let session = match read_owner(path) {
Err(why) => return Some(why),
Ok(None | Some(SentinelOwner::Unknown)) => None,
Ok(Some(SentinelOwner::Agent(owner, _))) => {
if (gate.agent_state)(&owner) == AgentDelegationState::Live {
return Some(format!(
"owned by dispatched agent {} — a delegation naming it has not ended \
(#5661, #7771)",
owner.agent_id
));
}
Some(owner.parent_session_id.0.to_string())
}
Ok(Some(SentinelOwner::Known(owner, _))) => Some(owner.to_string()),
};
if let Some(session) = session {
match (gate.session_end)(&session) {
SessionEnd::Caller | SessionEnd::Ended => {}
SessionEnd::Live => {
return Some(format!(
"its owner file names session {session}, which is live and is not the \
calling session — that session may still resume work here (#7771)"
));
}
SessionEnd::Undeterminable(why) => {
return Some(format!(
"its owner file names session {session}, and nothing proves that session \
ended: {why} (#7771, ADR-0045)"
));
}
}
}
(gate.cwd_holder)(path)
}
#[cfg(test)]
#[path = "worktree_owner_gate_tests.rs"]
mod worktree_owner_gate_tests;