use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::decommission::WORKTREE_SENTINEL_FILE;
use super::manager::{ManagedError, SessionManager};
use super::record::{ManagedSessionId, SessionRecord};
use crate::core::agent::DelegationId;
use crate::core::session::SessionId;
#[allow(unused_imports)]
pub(crate) use super::worktree_ownership_location::OwnerReadError;
pub(crate) use super::worktree_ownership_location::read_sentinel_owner_strict;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct WorktreeSentinel {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner_session_id: Option<ManagedSessionId>,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<AgentWorktreeOwner>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct AgentWorktreeOwner {
pub agent_id: String,
pub delegation_id: DelegationId,
pub parent_session_id: SessionId,
}
impl WorktreeSentinel {
pub(crate) fn new(owner: ManagedSessionId) -> Self {
Self {
owner_session_id: Some(owner),
created_at: Utc::now(),
agent: None,
}
}
pub(crate) fn for_agent(owner: AgentWorktreeOwner) -> Self {
Self {
owner_session_id: None,
created_at: Utc::now(),
agent: Some(owner),
}
}
}
pub(crate) fn sentinel_payload_bytes(owner: ManagedSessionId) -> Vec<u8> {
serde_json::to_vec(&WorktreeSentinel::new(owner)).unwrap_or_default()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SentinelOwner {
Known(ManagedSessionId, DateTime<Utc>),
Agent(AgentWorktreeOwner, DateTime<Utc>),
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AgentDelegationState {
Live,
Ended,
Unknown,
}
pub(crate) fn is_harness_agent_worktree(path: &Path) -> bool {
let Some(parent) = path.parent() else {
return false;
};
parent.file_name().is_some_and(|n| n == "worktrees")
&& parent
.parent()
.and_then(Path::file_name)
.is_some_and(|n| n == ".claude")
}
pub(crate) fn read_sentinel_owner(worktree_path: &Path) -> SentinelOwner {
read_sentinel_owner_strict(worktree_path)
.ok()
.flatten()
.unwrap_or(SentinelOwner::Unknown)
}
pub(crate) fn owner_from_payload(payload: WorktreeSentinel) -> SentinelOwner {
if let Some(agent) = payload.agent {
return SentinelOwner::Agent(agent, payload.created_at);
}
match payload.owner_session_id {
Some(owner) => SentinelOwner::Known(owner, payload.created_at),
None => SentinelOwner::Unknown,
}
}
pub(crate) fn write_agent_sentinel(
worktree_path: &Path,
owner: AgentWorktreeOwner,
) -> std::io::Result<()> {
let bytes = serde_json::to_vec(&WorktreeSentinel::for_agent(owner))
.map_err(|e| std::io::Error::other(format!("serialize agent sentinel: {e}")))?;
super::worktree_ownership_location::write_sentinel_bytes(worktree_path, &bytes)
}
pub(crate) fn take_worktree_sentinel(worktree_path: &Path) -> std::io::Result<Option<Vec<u8>>> {
let path = worktree_path.join(WORKTREE_SENTINEL_FILE);
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
std::fs::remove_file(&path)?;
Ok(Some(bytes))
}
pub(crate) fn restore_worktree_sentinel(worktree_path: &Path, bytes: &[u8]) -> std::io::Result<()> {
std::fs::write(worktree_path.join(WORKTREE_SENTINEL_FILE), bytes)
}
pub(crate) fn find_agent_worktree(store: &Path, agent_id: &str) -> Option<std::path::PathBuf> {
let entries = std::fs::read_dir(store).ok()?;
let mut matches: Vec<std::path::PathBuf> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if let SentinelOwner::Agent(owner, _) = read_sentinel_owner(&path)
&& owner.agent_id == agent_id
{
matches.push(path);
}
}
match matches.len() {
1 => matches.pop(),
0 => None,
n => {
tracing::warn!(
agent_id,
store = %store.display(),
"worktree ownership: {n} directories carry a sentinel naming this agent — \
which one it owns is undeterminable, so none is reclaimed (#4311, ADR-0045)"
);
None
}
}
}
pub(crate) const OWNERLESS_GRACE: chrono::Duration = chrono::Duration::minutes(10);
impl SessionManager {
pub async fn set_worktree_owner(
&self,
id: &ManagedSessionId,
owner: ManagedSessionId,
) -> Result<(), ManagedError> {
let mut record = self.get(id).await?;
record.worktree_owner = Some(owner);
self.store.write().await.upsert(record).await?;
Ok(())
}
pub async fn set_worktree_owner_best_effort(
&self,
id: &ManagedSessionId,
owner: ManagedSessionId,
) {
if let Err(e) = self.set_worktree_owner(id, owner).await {
tracing::warn!(id = %id, "set_worktree_owner failed (non-fatal): {e}");
}
}
pub(crate) async fn resolve_ownerless(&self, owner: ManagedSessionId) -> bool {
match self.get(&owner).await {
Ok(record) => record.state.is_terminal(),
Err(_) => true,
}
}
pub(crate) async fn resolve_ownerless_with_grace(
&self,
owner: ManagedSessionId,
sentinel_created_at: DateTime<Utc>,
) -> bool {
match self.get(&owner).await {
Ok(record) => record.state.is_terminal(),
Err(_) => Utc::now() - sentinel_created_at > OWNERLESS_GRACE,
}
}
pub(crate) fn known_owner_of(&self, record: &SessionRecord) -> Option<ManagedSessionId> {
if let Some(owner) = record.worktree_owner {
return Some(owner);
}
let ws = record.workspace_path.as_deref()?;
match read_sentinel_owner(ws) {
SentinelOwner::Known(owner, _created_at) => Some(owner),
SentinelOwner::Agent(..) | SentinelOwner::Unknown => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session_manager::record::{ManagedSessionState, SessionRecord};
use crate::session_manager::tests::FakeTmuxDriver;
#[test]
fn sentinel_owner_absent_file_is_unknown() {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(read_sentinel_owner(dir.path()), SentinelOwner::Unknown);
}
#[test]
fn sentinel_owner_empty_file_is_unknown() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join(WORKTREE_SENTINEL_FILE), b"").expect("write empty");
assert_eq!(read_sentinel_owner(dir.path()), SentinelOwner::Unknown);
}
#[test]
fn sentinel_owner_garbage_file_is_unknown() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join(WORKTREE_SENTINEL_FILE),
b"not json { garbage",
)
.expect("write garbage");
assert_eq!(read_sentinel_owner(dir.path()), SentinelOwner::Unknown);
}
#[test]
fn an_absent_and_an_unreadable_sentinel_are_both_unknown() {
let dir = tempfile::tempdir().expect("tempdir");
assert_eq!(read_sentinel_owner(dir.path()), SentinelOwner::Unknown);
std::fs::write(dir.path().join(WORKTREE_SENTINEL_FILE), b"not json {").expect("write");
assert_eq!(read_sentinel_owner(dir.path()), SentinelOwner::Unknown);
}
#[test]
fn sentinel_owner_round_trips_valid_payload() {
let dir = tempfile::tempdir().expect("tempdir");
let owner = ManagedSessionId::new();
let before = Utc::now();
std::fs::write(
dir.path().join(WORKTREE_SENTINEL_FILE),
sentinel_payload_bytes(owner),
)
.expect("write sentinel");
match read_sentinel_owner(dir.path()) {
SentinelOwner::Known(got_owner, created_at) => {
assert_eq!(got_owner, owner);
assert!(
created_at >= before && created_at <= Utc::now(),
"created_at must be freshly stamped at write time"
);
}
other => panic!("expected Known, got {other:?}"),
}
}
fn an_agent(agent_id: &str) -> AgentWorktreeOwner {
AgentWorktreeOwner {
agent_id: agent_id.to_string(),
delegation_id: DelegationId(uuid::Uuid::new_v4()),
parent_session_id: SessionId::new(),
}
}
#[test]
fn agent_sentinel_round_trips() {
let dir = tempfile::tempdir().expect("tempdir");
let owner = an_agent("a403cdbc078b5c474");
let before = Utc::now();
write_agent_sentinel(dir.path(), owner.clone()).expect("write agent sentinel");
match read_sentinel_owner(dir.path()) {
SentinelOwner::Agent(got, created_at) => {
assert_eq!(
got, owner,
"every recorded field must survive the round trip"
);
assert!(created_at >= before && created_at <= Utc::now());
}
other => panic!("an agent sentinel must resolve as Agent, got {other:?}"),
}
}
#[test]
fn a_pre_agent_sentinel_still_resolves_to_its_session_owner() {
let dir = tempfile::tempdir().expect("tempdir");
let owner = ManagedSessionId::new();
let legacy = format!(
r#"{{"owner_session_id":"{owner}","created_at":"{}"}}"#,
Utc::now().to_rfc3339()
);
std::fs::write(dir.path().join(WORKTREE_SENTINEL_FILE), legacy).expect("write legacy");
assert!(
matches!(read_sentinel_owner(dir.path()), SentinelOwner::Known(got, _) if got == owner),
"a payload written before #4311 must keep resolving to its session owner"
);
}
#[test]
fn a_sentinel_naming_no_owner_at_all_is_unknown() {
let dir = tempfile::tempdir().expect("tempdir");
let bytes = format!(r#"{{"created_at":"{}"}}"#, Utc::now().to_rfc3339());
std::fs::write(dir.path().join(WORKTREE_SENTINEL_FILE), bytes).expect("write");
assert_eq!(read_sentinel_owner(dir.path()), SentinelOwner::Unknown);
}
#[test]
fn agent_sentinel_write_fails_when_the_path_is_not_writable() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(dir.path().join(WORKTREE_SENTINEL_FILE)).expect("occupy the path");
let err = write_agent_sentinel(dir.path(), an_agent("a403"))
.expect_err("writing over a directory must fail, not silently succeed");
assert!(
!err.to_string().is_empty(),
"the reason must reach the caller"
);
assert_eq!(
read_sentinel_owner(dir.path()),
SentinelOwner::Unknown,
"nothing readable was written, so the tree stays owner-unknown"
);
}
#[test]
fn agent_sentinel_survives_a_lost_registry() {
let store = tempfile::tempdir().expect("tempdir");
for name in ["agent-one", "agent-two", "agent-three"] {
let wt = store.path().join(name);
std::fs::create_dir(&wt).expect("create worktree dir");
write_agent_sentinel(&wt, an_agent(name)).expect("write sentinel");
}
assert_eq!(
find_agent_worktree(store.path(), "agent-two"),
Some(store.path().join("agent-two")),
"the sentinel alone must name the directory, with no registry consulted"
);
}
#[test]
fn agent_sentinel_lookup_ignores_a_different_agent() {
let store = tempfile::tempdir().expect("tempdir");
let wt = store.path().join("agent-one");
std::fs::create_dir(&wt).expect("create worktree dir");
write_agent_sentinel(&wt, an_agent("a403")).expect("write sentinel");
std::fs::create_dir(store.path().join("agent-bare")).expect("create bare dir");
assert_eq!(find_agent_worktree(store.path(), "a404"), None);
assert_eq!(find_agent_worktree(store.path(), ""), None);
}
#[test]
fn agent_sentinel_lookup_refuses_an_ambiguous_match() {
let store = tempfile::tempdir().expect("tempdir");
for name in ["agent-first", "agent-second"] {
let wt = store.path().join(name);
std::fs::create_dir(&wt).expect("create worktree dir");
write_agent_sentinel(&wt, an_agent("a-moved-around")).expect("write sentinel");
}
assert_eq!(
find_agent_worktree(store.path(), "a-moved-around"),
None,
"two directories claiming one agent must reclaim neither"
);
}
#[test]
fn concurrent_agent_registrations_stay_separate() {
let store = tempfile::tempdir().expect("tempdir");
let paths: Vec<_> = (0..8)
.map(|i| {
let wt = store.path().join(format!("agent-{i}"));
std::fs::create_dir(&wt).expect("create worktree dir");
wt
})
.collect();
std::thread::scope(|s| {
for (i, wt) in paths.iter().enumerate() {
s.spawn(move || {
write_agent_sentinel(wt, an_agent(&format!("id-{i}"))).expect("write");
});
}
});
for (i, wt) in paths.iter().enumerate() {
assert_eq!(
find_agent_worktree(store.path(), &format!("id-{i}")).as_ref(),
Some(wt),
"each agent must resolve to its OWN directory, never a sibling's"
);
}
}
async fn manager_with_record(
state: ManagedSessionState,
) -> (SessionManager, ManagedSessionId, tempfile::TempDir) {
let dir = crate::test_support::hermetic_temp_dir();
let tmux = FakeTmuxDriver::new();
let mgr = SessionManager::new(dir.path(), tmux)
.await
.expect("SessionManager::new");
let id = ManagedSessionId::new();
let record = SessionRecord {
id,
tmux_name: "tm-ownerless-test".into(),
cwd: std::path::PathBuf::from("/tmp"),
task: "task".into(),
state,
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: None,
scrollback_path: None,
last_cwd: None,
deliverable_id: None,
pane_id: None,
tmux_server: None,
injection_status: Default::default(),
worktree_owner: Some(id),
terminal_at: None,
stop_cause: None,
kind: Default::default(),
};
mgr.store
.write()
.await
.upsert(record)
.await
.expect("upsert");
(mgr, id, dir)
}
#[tokio::test]
async fn resolve_ownerless_true_for_absent_owner() {
let dir = crate::test_support::hermetic_temp_dir();
let mgr = SessionManager::new(dir.path(), FakeTmuxDriver::new())
.await
.expect("SessionManager::new");
let never_existed = ManagedSessionId::new();
assert!(
mgr.resolve_ownerless(never_existed).await,
"an owner with no record at all must be provably ownerless"
);
}
#[tokio::test]
async fn resolve_ownerless_true_for_terminal_owner() {
let (mgr, id, _dir) = manager_with_record(ManagedSessionState::Decommissioned).await;
assert!(
mgr.resolve_ownerless(id).await,
"a terminal (Decommissioned) owner must be provably ownerless"
);
}
#[tokio::test]
async fn resolve_ownerless_false_for_live_owner() {
let (mgr, id, _dir) = manager_with_record(ManagedSessionState::Active).await;
assert!(
!mgr.resolve_ownerless(id).await,
"a live (Active) owner must NEVER be treated as ownerless"
);
}
#[tokio::test]
async fn resolve_ownerless_false_for_stopped_owner() {
let (mgr, id, _dir) = manager_with_record(ManagedSessionState::Stopped).await;
assert!(
!mgr.resolve_ownerless(id).await,
"a Stopped (resumable) owner must NEVER be treated as ownerless"
);
}
#[tokio::test]
async fn resolve_ownerless_with_grace_spares_recent_absent_owner() {
let dir = crate::test_support::hermetic_temp_dir();
let mgr = SessionManager::new(dir.path(), FakeTmuxDriver::new())
.await
.expect("SessionManager::new");
let never_existed = ManagedSessionId::new();
assert!(
!mgr.resolve_ownerless_with_grace(never_existed, Utc::now())
.await,
"a freshly-stamped absent owner must NOT be treated as ownerless \
(mid-creation race, #3649)"
);
}
#[tokio::test]
async fn resolve_ownerless_with_grace_reclaims_aged_absent_owner() {
let dir = crate::test_support::hermetic_temp_dir();
let mgr = SessionManager::new(dir.path(), FakeTmuxDriver::new())
.await
.expect("SessionManager::new");
let never_existed = ManagedSessionId::new();
let aged = Utc::now() - OWNERLESS_GRACE - chrono::Duration::minutes(1);
assert!(
mgr.resolve_ownerless_with_grace(never_existed, aged).await,
"an absent owner older than the grace window must be reclaimable"
);
}
#[tokio::test]
async fn resolve_ownerless_with_grace_true_for_terminal_owner_regardless_of_age() {
let (mgr, id, _dir) = manager_with_record(ManagedSessionState::Decommissioned).await;
assert!(
mgr.resolve_ownerless_with_grace(id, Utc::now()).await,
"a terminal owner must be ownerless even with a freshly-stamped sentinel"
);
}
#[tokio::test]
async fn set_worktree_owner_round_trips() {
let (mgr, id, _dir) = manager_with_record(ManagedSessionState::Active).await;
mgr.set_worktree_owner(&id, id)
.await
.expect("set_worktree_owner");
let record = mgr.get(&id).await.expect("get");
assert_eq!(record.worktree_owner, Some(id));
}
#[tokio::test]
async fn known_owner_of_falls_back_to_sentinel() {
let (mgr, _id, _dir) = manager_with_record(ManagedSessionState::Active).await;
let ws = tempfile::tempdir().expect("tempdir");
let sentinel_owner = ManagedSessionId::new();
std::fs::write(
ws.path().join(WORKTREE_SENTINEL_FILE),
sentinel_payload_bytes(sentinel_owner),
)
.expect("write sentinel");
let record = SessionRecord {
id: ManagedSessionId::new(),
tmux_name: "tm-fallback-test".into(),
cwd: ws.path().to_path_buf(),
task: "task".into(),
state: ManagedSessionState::Active,
created_at: Utc::now(),
last_activity_at: None,
workspace_path: Some(ws.path().to_path_buf()),
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: None,
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(),
};
assert_eq!(mgr.known_owner_of(&record), Some(sentinel_owner));
}
}