use std::path::PathBuf;
use crate::core::agent::{Delegation, DelegationId, DelegationSource, DelegationStatus};
use crate::core::project::ProjectInfo;
use crate::core::session::{Session, SessionId};
use super::core::PAIR_CODE_TTL;
use super::core::{DaemonState, ReapResult};
use crate::daemon::tmux::TmuxDriver;
pub(crate) const RUNNING_STALE_AFTER_SECS: i64 = 6 * 60 * 60;
pub(crate) const DECLARED_STALE_AFTER_SECS: i64 = 15 * 60;
pub(crate) const DELEGATION_RETENTION_SECS: i64 = 60 * 60;
pub(crate) const STALE_RETENTION_SECS: i64 = 24 * 60 * 60;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct DelegationSweep {
pub staled: usize,
pub evicted: usize,
}
impl DaemonState {
pub fn generate_pair_code(&self) -> String {
let code: String = uuid::Uuid::new_v4()
.simple()
.to_string()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(6)
.collect::<String>()
.to_uppercase();
*self.pair_code.lock() = Some((code.clone(), std::time::Instant::now()));
let pending = crate::daemon::pairing_store::PendingPairCode::new(code.clone());
if let Err(e) = crate::daemon::pairing_store::save_pending(&self.framework_root, &pending) {
tracing::warn!("failed to persist pending Telegram pairing code: {e}");
}
code
}
pub fn confirm_pair_code(&self, code: &str, chat_id: i64) -> bool {
use crate::daemon::pairing_store;
let mut guard = self.pair_code.lock();
let disk_valid = pairing_store::claim_pending(&self.framework_root)
.is_some_and(|p| p.code == code && p.is_fresh(PAIR_CODE_TTL));
let mem_valid = matches!(
guard.as_ref(),
Some((stored, issued))
if stored == code && issued.elapsed() < PAIR_CODE_TTL
);
let valid = disk_valid || mem_valid;
*guard = None;
if let Err(e) = pairing_store::clear_pending(&self.framework_root) {
tracing::warn!("failed to clear pending Telegram pairing code: {e}");
}
if valid {
*self.paired_chat_id.lock() = Some(chat_id);
let record = pairing_store::PairingRecord::new(chat_id);
if let Err(e) = pairing_store::save(&self.framework_root, &record) {
tracing::warn!("failed to persist Telegram pairing: {e}");
}
}
valid
}
pub fn clear_pairing(&self) {
*self.paired_chat_id.lock() = None;
*self.pair_code.lock() = None;
if let Err(e) = crate::daemon::pairing_store::clear(&self.framework_root) {
tracing::warn!("failed to delete persisted Telegram pairing: {e}");
}
if let Err(e) = crate::daemon::pairing_store::clear_pending(&self.framework_root) {
tracing::warn!("failed to delete pending Telegram pairing code: {e}");
}
}
pub fn paired_chat_id(&self) -> Option<i64> {
*self.paired_chat_id.lock()
}
pub fn register_session(&self, session: Session) {
self.sessions.insert(session.id, session);
}
pub fn set_session_pid(&self, id: SessionId, pid: u32) -> bool {
let updated = self.update_session(&id, |s| s.pid = Some(pid));
if updated && let Err(e) = self.pid_registry().register(&id.0.to_string(), pid) {
tracing::warn!(session_id = %id.0, pid, "pid-registry: register failed: {e}");
}
updated
}
pub fn remove_session(&self, id: SessionId) -> Option<Session> {
self.memory.remove(&id);
if let Err(e) = self.pid_registry().unregister(&id.0.to_string()) {
tracing::warn!(session_id = %id.0, "pid-registry: unregister failed: {e}");
}
self.sessions.remove(&id).map(|(_, s)| s)
}
pub fn list_sessions(&self) -> Vec<Session> {
self.sessions.iter().map(|e| e.value().clone()).collect()
}
pub fn session(&self, id: SessionId) -> Option<Session> {
self.sessions.get(&id).map(|e| e.value().clone())
}
pub fn update_session<F>(&self, id: &SessionId, f: F) -> bool
where
F: FnOnce(&mut Session),
{
match self.sessions.get_mut(id) {
Some(mut entry) => {
f(entry.value_mut());
true
}
None => false,
}
}
pub fn list_sessions_for_project(&self, path: &std::path::Path) -> Vec<Session> {
self.sessions
.iter()
.filter(|e| e.value().project_path.as_deref() == Some(path))
.map(|e| e.value().clone())
.collect()
}
pub fn find_session(&self, key: &str) -> Option<Session> {
if let Ok(uuid) = uuid::Uuid::parse_str(key) {
return self.session(SessionId(uuid));
}
self.sessions
.iter()
.find(|e| e.value().tmux_name == key)
.map(|e| e.value().clone())
}
pub fn reap_dead_sessions(&self, driver: &TmuxDriver) -> ReapResult {
let live: std::collections::HashSet<String> = match driver.list_sessions() {
Ok(sessions) => sessions.into_iter().map(|s| s.name).collect(),
Err(e) => {
tracing::warn!("reap skipped — tmux list-sessions failed: {e}");
return ReapResult::default();
}
};
self.reap_against(&live)
}
pub(super) fn reap_against(&self, live: &std::collections::HashSet<String>) -> ReapResult {
use crate::core::session::{SessionHost, SessionStatus};
let mut dead: Vec<SessionId> = Vec::new();
let mut stopped_ids: Vec<SessionId> = Vec::new();
for entry in self.sessions.iter() {
let session = entry.value();
if session.origin != SessionHost::Tmux {
continue;
}
if !live.contains(&session.tmux_name) {
dead.push(*entry.key());
} else if session.status != SessionStatus::Stopped
&& let Some(pid) = session.pid
&& !crate::core::process::is_process_alive(pid)
{
stopped_ids.push(*entry.key());
}
}
for id in &dead {
self.remove_session(*id);
}
for id in &stopped_ids {
self.update_session(id, |s| s.status = SessionStatus::Stopped);
}
ReapResult {
reaped: dead.len(),
stopped: stopped_ids.len(),
}
}
pub async fn gather_tracked_names(&self) -> crate::daemon::orphan_gc::TrackedNames {
let legacy: std::collections::HashSet<String> = self
.sessions
.iter()
.map(|e| e.value().tmux_name.clone())
.collect();
match self.session_manager().await.known_tmux_names().await {
Ok(managed) => crate::daemon::orphan_gc::TrackedNames {
legacy,
managed,
degraded: false,
},
Err(e) => {
tracing::warn!(
"orphan-GC: store read for known names failed: {e}; \
marking tracked-names snapshot degraded (sweep will not reap)"
);
crate::daemon::orphan_gc::TrackedNames {
legacy,
managed: std::collections::HashSet::new(),
degraded: true,
}
}
}
}
pub async fn gather_live_session_ids(&self) -> std::collections::HashSet<String> {
let mut ids: std::collections::HashSet<String> = self
.sessions
.iter()
.map(|e| e.key().0.to_string())
.collect();
let mgr = self.session_manager().await;
for record in mgr.list().await {
ids.insert(record.id.0.to_string());
}
ids
}
pub fn register_project(&self, path: PathBuf) -> ProjectInfo {
let info = ProjectInfo::new(path.clone());
self.projects.write().insert(path, info.clone());
info
}
pub fn list_projects(&self) -> Vec<ProjectInfo> {
self.projects.read().values().cloned().collect()
}
pub fn project(&self, path: &std::path::Path) -> Option<ProjectInfo> {
self.projects.read().get(path).cloned()
}
pub fn upsert_delegation(&self, delegation: Delegation) {
self.delegations.insert(delegation.id.0, delegation);
}
pub fn delegations_for(&self, session: SessionId) -> Vec<Delegation> {
self.delegations
.iter()
.filter(|e| e.value().session == session)
.map(|e| e.value().clone())
.collect()
}
pub fn find_delegation(
&self,
session: SessionId,
pred: impl Fn(&Delegation) -> bool,
) -> Option<DelegationId> {
self.delegations
.iter()
.find(|e| e.value().session == session && pred(e.value()))
.map(|e| e.value().id)
}
pub fn latest_delegation_matching(
&self,
session: SessionId,
pred: impl Fn(&Delegation) -> bool,
) -> Option<DelegationId> {
self.delegations
.iter()
.filter(|e| e.value().session == session && pred(e.value()))
.max_by_key(|e| e.value().created_at)
.map(|e| e.value().id)
}
pub fn sweep_delegations(&self) -> DelegationSweep {
self.sweep_delegations_at(chrono::Utc::now())
}
pub(crate) fn sweep_delegations_at(
&self,
now: chrono::DateTime<chrono::Utc>,
) -> DelegationSweep {
let mut sweep = DelegationSweep::default();
self.delegations.retain(|_, d| {
let age_from = d.started_at.unwrap_or(d.created_at);
if d.status.is_live() {
let budget = if d.status == DelegationStatus::Queued
&& d.source == DelegationSource::McpDeclared
{
DECLARED_STALE_AFTER_SECS
} else {
RUNNING_STALE_AFTER_SECS
};
if now - age_from > chrono::Duration::seconds(budget) {
d.status = DelegationStatus::Stale;
sweep.staled += 1;
}
return true;
}
let keep = if d.status == DelegationStatus::Stale {
now - age_from <= chrono::Duration::seconds(STALE_RETENTION_SECS)
} else {
now - d.ended_at.unwrap_or(d.created_at)
<= chrono::Duration::seconds(DELEGATION_RETENTION_SECS)
};
if !keep {
sweep.evicted += 1;
}
keep
});
sweep
}
pub fn mutate_delegation(&self, id: DelegationId, f: impl FnOnce(&mut Delegation)) -> bool {
match self.delegations.get_mut(&id.0) {
Some(mut entry) => {
f(entry.value_mut());
true
}
None => false,
}
}
pub fn terminate_delegation(&self, id: DelegationId, status: DelegationStatus) -> bool {
self.mutate_delegation(id, |d| {
d.status = status;
d.ended_at = Some(chrono::Utc::now());
})
}
pub async fn reap_dead_managed_sessions(&self, driver: &TmuxDriver) {
let live: std::collections::HashSet<String> = match driver.list_sessions() {
Ok(s) => s.into_iter().map(|s| s.name).collect(),
Err(e) => {
tracing::warn!("managed reap skipped — tmux list-sessions failed: {e}");
return;
}
};
self.reap_managed_against(&live).await;
}
pub(crate) async fn reap_managed_against(&self, live: &std::collections::HashSet<String>) {
let mgr = self.session_manager().await;
let records = mgr.list().await;
for r in records {
if matches!(r.state, crate::session_manager::ManagedSessionState::Active)
&& !live.contains(&r.tmux_name)
{
match mgr.stop(&r.id).await {
Ok(_) => tracing::info!(
id = %r.id,
name = %r.tmux_name,
"reaper: marked managed session Stopped (tmux gone, #1744)"
),
Err(e) => tracing::warn!(
id = %r.id,
name = %r.tmux_name,
"reaper: failed to mark managed session Stopped: {e}"
),
}
}
}
}
}