use std::path::PathBuf;
use crate::core::agent::{Delegation, DelegationId, DelegationSource, DelegationStatus};
use crate::core::project::ProjectInfo;
use crate::core::session::{Session, SessionId};
use crate::core::twin_identity::ClaudeProcess;
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, Clone, Copy, PartialEq, Eq)]
pub enum SharedTreeQuestion {
Dispatch,
HeadWrite { caller: Option<SessionId> },
}
fn type_reconciled_occupant(d: &Delegation, now: chrono::DateTime<chrono::Utc>) -> bool {
d.stale_by_agent_type
&& d.status == DelegationStatus::Stale
&& now - d.started_at.unwrap_or(d.created_at)
<= chrono::Duration::seconds(RUNNING_STALE_AFTER_SECS)
}
#[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 {
self.reap_against_with(live, super::session_claude_liveness::claude_liveness)
}
pub(super) fn reap_against_with(
&self,
live: &std::collections::HashSet<String>,
probe: impl Fn(ClaudeProcess) -> super::session_claude_liveness::ClaudeLiveness,
) -> ReapResult {
use crate::core::session::{SessionHost, SessionStatus};
let mut dead: Vec<SessionId> = Vec::new();
let mut stopped_ids: Vec<SessionId> = Vec::new();
let mut bound: Vec<(SessionId, ClaudeProcess)> = Vec::new();
for entry in self.sessions.iter() {
let session = entry.value();
if self.session_claudes.is_settled(*entry.key()) {
if let Some(claude) = self.session_claudes.get(*entry.key()) {
bound.push((*entry.key(), claude));
}
continue;
}
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);
self.stale_delegations_of_dead_session(*id);
}
for id in &stopped_ids {
self.update_session(id, |s| s.status = SessionStatus::Stopped);
self.stale_delegations_of_dead_session(*id);
}
ReapResult {
reaped: dead.len() + self.reap_settled_sessions(bound, probe),
stopped: stopped_ids.len(),
}
}
pub(crate) fn stale_delegations_of_dead_session(&self, session: SessionId) -> usize {
let mut staled = 0;
for mut entry in self.delegations.iter_mut() {
let d = entry.value_mut();
if d.session != session || !d.status.is_live() {
continue;
}
d.status = DelegationStatus::Stale;
staled += 1;
}
if staled > 0 {
tracing::info!(
session = ?session,
staled,
"delegation: owning session is gone — its live records are stale (#6497)"
);
}
staled
}
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(crate) fn dispatch_record_guard(&self) -> parking_lot::MutexGuard<'_, ()> {
self.dispatch_record.lock()
}
pub(crate) fn pending_stops(&self) -> &super::pending_stops::PendingStops {
&self.pending_stops
}
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 all_delegations(&self) -> Vec<Delegation> {
self.delegations.iter().map(|e| e.value().clone()).collect()
}
pub fn live_shared_tree_writers(
&self,
cwd: &std::path::Path,
exclude_tool_use_id: Option<&str>,
) -> Vec<String> {
self.shared_tree_agents(cwd, exclude_tool_use_id, false, None)
}
pub fn live_shared_tree_writers_excluding(
&self,
cwd: &std::path::Path,
exclude_tool_use_id: Option<&str>,
caller: Option<SessionId>,
) -> Vec<String> {
self.shared_tree_agents(cwd, exclude_tool_use_id, false, caller)
}
pub fn shared_tree_occupants(
&self,
cwd: &std::path::Path,
exclude_tool_use_id: Option<&str>,
) -> Vec<String> {
self.shared_tree_agents(cwd, exclude_tool_use_id, true, None)
}
fn shared_tree_agents(
&self,
cwd: &std::path::Path,
exclude_tool_use_id: Option<&str>,
include_reconciled: bool,
exclude_session: Option<SessionId>,
) -> Vec<String> {
self.shared_tree_records(
cwd,
exclude_tool_use_id,
include_reconciled,
exclude_session,
)
.into_iter()
.map(|d| d.agent)
.collect()
}
pub(crate) fn shared_tree_records(
&self,
cwd: &std::path::Path,
exclude_tool_use_id: Option<&str>,
include_reconciled: bool,
exclude_session: Option<SessionId>,
) -> Vec<Delegation> {
let now = chrono::Utc::now();
let roster = std::cell::OnceCell::new();
self.delegations
.iter()
.filter(|e| {
let d = e.value();
let counts =
d.status.is_live() || (include_reconciled && type_reconciled_occupant(d, now));
counts
&& exclude_session != Some(d.session)
&& !(exclude_tool_use_id.is_some()
&& d.tool_use_id.as_deref() == exclude_tool_use_id)
&& super::tree_membership::writes_in(
d,
cwd,
roster
.get_or_init(|| {
crate::core::content_source::agent_roster_for_query(cwd)
})
.as_ref(),
)
})
.map(|e| e.value().clone())
.collect()
}
pub fn claim_shared_tree_dispatch<F: FnOnce(&Self)>(
&self,
cwd: &std::path::Path,
exclude_tool_use_id: Option<&str>,
eligible: bool,
question: SharedTreeQuestion,
record: F,
) -> (Vec<String>, bool) {
let _claim = self.shared_tree_claim.lock();
let occupants = self.shared_tree_occupants(cwd, exclude_tool_use_id);
let claimed = eligible && occupants.is_empty();
if claimed {
record(self);
}
let names = match question {
SharedTreeQuestion::Dispatch => occupants,
SharedTreeQuestion::HeadWrite { caller } => {
self.live_shared_tree_writers_excluding(cwd, exclude_tool_use_id, caller)
}
};
(names, claimed)
}
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();
let expired = self.pending_stops.prune_at(now);
if expired > 0 {
tracing::debug!(expired, "delegation: pruned expired deferred stops (#4142)");
}
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;
mgr.follow_tmux_renames().await;
let records = mgr.list().await;
let cause = if live.is_empty() {
crate::session_manager::StopCause::Unexpected
} else {
crate::session_manager::StopCause::Deliberate
};
for r in records {
if matches!(r.state, crate::session_manager::ManagedSessionState::Active)
&& !live.contains(&r.tmux_name)
{
if mgr.is_resume_in_flight(&r.id) {
tracing::info!(
id = %r.id,
name = %r.tmux_name,
"reaper: a resume is in flight for this session; leaving it alone (#8233)"
);
continue;
}
let stopped = if r.kind.is_protected() {
mgr.mark_stopped_record_only(&r.id, cause).await
} else {
mgr.stop_with_cause(&r.id, cause).await
};
match stopped {
Ok(_) => tracing::info!(
id = %r.id,
name = %r.tmux_name,
cause = ?cause,
"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}"
),
}
}
}
}
}