use std::path::Path;
use crate::core::agent::Delegation;
use crate::session_manager::worktree_registry::{
RegisteredWorktree, harness_lock_pid_in_reason, is_harness_agent_lock_reason,
list_registered_worktrees, pid_liveness,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum LiveEvidence {
Clear,
Held(String),
Undeterminable(String),
}
const START_TOLERANCE_SECS: i64 = 2;
pub(crate) fn probe_live_evidence(d: &Delegation) -> LiveEvidence {
probe_live_evidence_with(
d,
crate::session_manager::worktree_liveness::process_holding,
)
}
pub(crate) fn probe_live_evidence_with(
d: &Delegation,
process_holding: impl Fn(&Path) -> Option<String>,
) -> LiveEvidence {
let Some(tree) = own_tree(d) else {
return agent_lock_evidence(d);
};
match std::fs::symlink_metadata(tree) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return LiveEvidence::Clear,
Err(e) => {
return LiveEvidence::Undeterminable(format!(
"could not stat the agent's tree {}: {e}",
tree.display()
));
}
Ok(_) => {}
}
if let Some(reason) = process_holding(tree) {
return LiveEvidence::Held(reason);
}
let canonical = match std::fs::canonicalize(tree) {
Ok(p) => p,
Err(e) => {
return LiveEvidence::Undeterminable(format!(
"could not canonicalize {}: {e}",
tree.display()
));
}
};
as_evidence(harness_lock_in(tree, |w| {
std::fs::canonicalize(&w.path).unwrap_or_else(|_| w.path.clone()) == canonical
}))
}
fn agent_lock_evidence(d: &Delegation) -> LiveEvidence {
let (Some(agent_id), Some(cwd)) = (d.agent_id.as_deref(), d.cwd.as_deref()) else {
return LiveEvidence::Clear;
};
if let Err(e) = std::fs::symlink_metadata(cwd) {
return match e.kind() {
std::io::ErrorKind::NotFound => LiveEvidence::Clear,
_ => LiveEvidence::Undeterminable(format!("could not stat {}: {e}", cwd.display())),
};
}
let token = format!("agent-{agent_id}");
as_evidence(harness_lock_in(cwd, |w| {
w.lock_reason
.as_deref()
.is_some_and(|r| r.split_whitespace().any(|t| t == token))
}))
}
fn as_evidence(read: Result<Option<String>, String>) -> LiveEvidence {
match read {
Ok(None) => LiveEvidence::Clear,
Ok(Some(reason)) => LiveEvidence::Held(reason),
Err(reason) => LiveEvidence::Undeterminable(reason),
}
}
fn own_tree(d: &Delegation) -> Option<&Path> {
let tree = d.worktree_path.as_deref()?;
(d.cwd.as_deref() != Some(tree)).then_some(tree)
}
fn harness_lock_in(
anchor: &Path,
pick: impl Fn(&RegisteredWorktree) -> bool,
) -> Result<Option<String>, String> {
let Some(registered) = list_registered_worktrees(anchor) else {
if outside_any_repository(anchor)? {
return Ok(None);
}
return Err(format!(
"git could not list the worktrees registered for {}, so no harness lock was read",
anchor.display()
));
};
let reason = registered
.into_iter()
.filter(|w| w.locked && pick(w))
.find_map(|w| w.lock_reason.filter(|r| is_harness_agent_lock_reason(r)));
match reason {
Some(reason) => lock_holder_evidence(&reason, pid_liveness, process_start_epoch),
None => Ok(None),
}
}
fn outside_any_repository(anchor: &Path) -> Result<bool, String> {
let canonical = std::fs::canonicalize(anchor)
.map_err(|e| format!("could not canonicalize {}: {e}", anchor.display()))?;
for dir in canonical.ancestors() {
let dot_git = dir.join(".git");
match std::fs::symlink_metadata(&dot_git) {
Ok(_) => return Ok(false),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("could not stat {}: {e}", dot_git.display())),
}
}
Ok(true)
}
pub(crate) fn lock_holder_evidence(
reason: &str,
pid_alive: impl Fn(u32) -> Option<bool>,
start_of: impl Fn(u32) -> Option<i64>,
) -> Result<Option<String>, String> {
lock_holder_evidence_in(reason, pid_alive, start_of, &chrono::Local)
}
pub(crate) fn lock_holder_evidence_in<Tz: chrono::TimeZone>(
reason: &str,
pid_alive: impl Fn(u32) -> Option<bool>,
start_of: impl Fn(u32) -> Option<i64>,
local: &Tz,
) -> Result<Option<String>, String> {
let pid = harness_lock_pid_in_reason(reason)
.ok_or_else(|| format!("the harness lock `{reason}` names no pid"))?;
match pid_alive(pid) {
Some(false) => return Ok(None),
None => {
return Err(format!(
"could not tell whether harness lock pid {pid} runs"
));
}
Some(true) => {}
}
let stamp = lock_start_stamp(reason).ok_or_else(|| {
format!("harness lock pid {pid} runs, but the lock carries no readable start time")
})?;
let actual = start_of(pid)
.ok_or_else(|| format!("could not read the start time of running pid {pid}"))?;
let as_utc = stamp.and_utc().timestamp();
let as_local = local.from_local_datetime(&stamp);
let near = |at: i64| (actual - at).abs() <= START_TOLERANCE_SECS;
let matches = near(as_utc)
|| [as_local.clone().earliest(), as_local.latest()]
.into_iter()
.flatten()
.any(|t| near(t.timestamp()));
if matches {
return Ok(Some(format!(
"harness lock pid {pid} is running and is the process that took the lock"
)));
}
let skew = actual - as_utc;
if is_zone_offset(skew) {
return Err(format!(
"harness lock pid {pid} runs, and its start time sits {skew} s from the lock's — a \
whole time-zone offset, so a reused pid cannot be told from the lock's own holder \
read in another zone"
));
}
Ok(None)
}
fn is_zone_offset(skew: i64) -> bool {
const QUARTER_HOUR: i64 = 15 * 60;
let off_grid = skew.rem_euclid(QUARTER_HOUR);
skew.abs() <= 14 * 3600 + START_TOLERANCE_SECS
&& off_grid.min(QUARTER_HOUR - off_grid) <= START_TOLERANCE_SECS
}
fn lock_start_stamp(reason: &str) -> Option<chrono::NaiveDateTime> {
let rest = reason.split_once(" start ")?.1;
let stamp = rest.split(')').next()?;
let stamp = stamp.split_whitespace().collect::<Vec<_>>().join(" ");
chrono::NaiveDateTime::parse_from_str(&stamp, "%a %b %d %H:%M:%S %Y").ok()
}
fn process_start_epoch(pid: u32) -> Option<i64> {
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
let pid = Pid::from_u32(pid);
let mut sys = System::new();
sys.refresh_processes_specifics(
ProcessesToUpdate::Some(&[pid]),
true,
ProcessRefreshKind::nothing(),
);
sys.process(pid)
.and_then(|p| i64::try_from(p.start_time()).ok())
}