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};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct WorktreeSentinel {
pub owner_session_id: ManagedSessionId,
pub created_at: DateTime<Utc>,
}
impl WorktreeSentinel {
pub(crate) fn new(owner: ManagedSessionId) -> Self {
Self {
owner_session_id: owner,
created_at: Utc::now(),
}
}
}
pub(crate) fn sentinel_payload_bytes(owner: ManagedSessionId) -> Vec<u8> {
serde_json::to_vec(&WorktreeSentinel::new(owner)).unwrap_or_default()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SentinelOwner {
Known(ManagedSessionId, DateTime<Utc>),
Unknown,
}
pub(crate) fn read_sentinel_owner(worktree_path: &Path) -> SentinelOwner {
let sentinel_path = worktree_path.join(WORKTREE_SENTINEL_FILE);
let Ok(bytes) = std::fs::read(&sentinel_path) else {
return SentinelOwner::Unknown;
};
if bytes.is_empty() {
return SentinelOwner::Unknown;
}
match serde_json::from_slice::<WorktreeSentinel>(&bytes) {
Ok(payload) => SentinelOwner::Known(payload.owner_session_id, payload.created_at),
Err(_) => SentinelOwner::Unknown,
}
}
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::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 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"
);
}
SentinelOwner::Unknown => panic!("expected Known, got Unknown"),
}
}
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,
injection_status: Default::default(),
worktree_owner: Some(id),
terminal_at: None,
};
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,
injection_status: Default::default(),
worktree_owner: None, terminal_at: None,
};
assert_eq!(mgr.known_owner_of(&record), Some(sentinel_owner));
}
}