use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use chrono::{DateTime, Utc};
use crate::agent::message_router::{AgentJob, MessageKind};
use crate::agent::registry::AGENT_REGISTRY;
use crate::session::SessionContext;
use crate::{ChatRole, Role};
const POLL_INTERVAL: Duration = Duration::from_mins(5);
const MAX_RETRIES: u8 = 10;
const BASE_BACKOFF_MINUTES: i64 = 10;
const MAX_BACKOFF_MINUTES: i64 = 40;
const STALE_GRACE_PERIOD: Duration = Duration::from_mins(5);
struct RetryState {
attempt_count: u8,
last_attempt_at: DateTime<Utc>,
backoff_minutes: i64,
}
pub(crate) struct DeadSessionTracker {
inner: Mutex<HashMap<String, RetryState>>,
}
impl DeadSessionTracker {
pub(crate) fn new() -> Self {
Self {
inner: Mutex::new(HashMap::new()),
}
}
fn should_retry(&self, agent_id: &str) -> bool {
let map = self.inner.lock().expect("DeadSessionTracker lock poisoned");
match map.get(agent_id) {
Some(state) => {
if state.attempt_count >= MAX_RETRIES {
return false;
}
let elapsed = (Utc::now() - state.last_attempt_at).num_minutes();
elapsed >= state.backoff_minutes
}
None => true,
}
}
fn record_attempt(&self, agent_id: &str) {
let mut map = self.inner.lock().expect("DeadSessionTracker lock poisoned");
let state = map.entry(agent_id.to_string()).or_insert(RetryState {
attempt_count: 0,
last_attempt_at: Utc::now(),
backoff_minutes: BASE_BACKOFF_MINUTES,
});
state.attempt_count += 1;
state.last_attempt_at = Utc::now();
state.backoff_minutes = (state.backoff_minutes * 2).min(MAX_BACKOFF_MINUTES);
}
fn cleanup(&self, agent_id: &str) {
self.inner
.lock()
.expect("DeadSessionTracker lock poisoned")
.remove(agent_id);
}
#[cfg(test)]
fn has_exhausted_retries(&self, agent_id: &str) -> bool {
let map = self.inner.lock().expect("DeadSessionTracker lock poisoned");
map.get(agent_id)
.is_some_and(|s| s.attempt_count >= MAX_RETRIES)
}
}
pub(crate) static DEAD_SESSION_TRACKER: std::sync::LazyLock<DeadSessionTracker> =
std::sync::LazyLock::new(DeadSessionTracker::new);
pub async fn run_dead_session_recovery_loop() {
loop {
if !crate::shutdown::sleep_or_shutdown_or_drain(POLL_INTERVAL).await {
break;
}
if let Err(e) = recover_dead_sessions().await {
tracing::warn!(error = %e, "Dead session recovery poller failed");
}
}
}
fn is_recovery_candidate(last_role: ChatRole) -> bool {
matches!(last_role, ChatRole::User | ChatRole::Tool)
}
async fn recover_dead_sessions() -> anyhow::Result<()> {
let now = Utc::now();
let sessions = crate::session::store()
.list_sessions_with_metadata_excluding(
&crate::session::reserved_agent_id_prefixes().collect::<Vec<&str>>(),
)
.await;
for session in &sessions {
let agent_id = &session.agent_id;
let Some(last_role) = crate::session::store()
.get_last_message_role(agent_id)
.await
else {
DEAD_SESSION_TRACKER.cleanup(agent_id);
continue; };
if !is_recovery_candidate(last_role) {
DEAD_SESSION_TRACKER.cleanup(agent_id);
continue;
}
if AGENT_REGISTRY.contains(agent_id) {
continue;
}
let age = now - session.last_activity;
let grace = chrono::Duration::from_std(STALE_GRACE_PERIOD)
.expect("STALE_GRACE_PERIOD fits in chrono::Duration");
if age < grace {
continue;
}
if !DEAD_SESSION_TRACKER.should_retry(agent_id) {
continue;
}
let Some(ctx) = crate::session::store().get_session_context(agent_id).await else {
DEAD_SESSION_TRACKER.cleanup(agent_id);
tracing::warn!(
agent_id = %agent_id,
"Dead session recovery: no context found \
(corrupted data) — skipping permanently"
);
continue;
};
let Ok(role) = ctx.role.parse::<Role>() else {
DEAD_SESSION_TRACKER.cleanup(agent_id);
tracing::warn!(
agent_id = %agent_id,
role = %ctx.role,
"Dead session recovery: invalid role in session context — \
skipping permanently"
);
continue;
};
attempt_recovery(agent_id, &ctx, role);
DEAD_SESSION_TRACKER.record_attempt(agent_id);
}
Ok(())
}
#[cfg(test)]
fn is_excluded_agent_id(agent_id: &str) -> bool {
crate::session::reserved_agent_id_prefixes().any(|p| {
let bare = p.trim_end_matches('_');
agent_id.len() > bare.len() && crate::session::starts_with_ignore_ascii_case(agent_id, bare)
})
}
fn attempt_recovery(agent_id: &str, ctx: &SessionContext, role: Role) {
let job = AgentJob {
content: String::new(),
workspace_name: ctx.workspace_name.clone(),
user_name: ctx.user_name.clone(),
channel: ctx.channel.clone(),
kind: MessageKind::RecoveryRetry,
role,
reply_target: None,
pending_job_id: None,
};
tracing::info!(
agent_id = %agent_id,
role = %role.as_str(),
workspace = %ctx.workspace_name,
user = %ctx.user_name,
channel = %ctx.channel,
"Dead session recovery: routing retry job"
);
crate::agent::message_router::route(agent_id, job);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_excluded_agent_id_cases() {
for prefix in crate::session::reserved_agent_id_prefixes() {
let id = format!("{prefix}suffix");
assert!(
is_excluded_agent_id(&id),
"case: transient prefix '{prefix}' — expected '{id}' to be excluded",
);
}
struct Case {
name: &'static str,
id: String,
excluded: bool,
}
let cases = vec![
Case {
name: "direct_session",
id: "alice_main_workspace_engineer".into(),
excluded: false,
},
Case {
name: "direct_session_ws_2",
id: "bob_my_project_analyst".into(),
excluded: false,
},
Case {
name: "direct_session_ws_3",
id: "charlie_personal_work_assistant".into(),
excluded: false,
},
Case {
name: "underscore_in_names",
id: "some_user_my_cool_workspace_reviewer".into(),
excluded: false,
},
Case {
name: "colliding_user_manager",
id: crate::session::direct_agent_id("manager", "engineer", "ws"),
excluded: false,
},
Case {
name: "colliding_user_ticket",
id: crate::session::direct_agent_id("ticket_bob", "analyst", "ws"),
excluded: false,
},
Case {
name: "case_variant_ticket_upper",
id: "Ticket_suffix".into(),
excluded: true,
},
Case {
name: "case_variant_ticket_mixed",
id: "tIcKeT_suffix".into(),
excluded: true,
},
Case {
name: "case_variant_manager",
id: "Manager_bob".into(),
excluded: true,
},
];
for case in &cases {
assert_eq!(
is_excluded_agent_id(&case.id),
case.excluded,
"case: {name} — id='{id}'",
name = case.name,
id = case.id,
);
}
}
#[test]
fn test_is_recovery_candidate_classification() {
assert!(is_recovery_candidate(ChatRole::User));
assert!(is_recovery_candidate(ChatRole::Tool));
assert!(!is_recovery_candidate(ChatRole::Assistant));
assert!(!is_recovery_candidate(ChatRole::System));
}
#[test]
fn test_dead_session_tracker_max_retries() {
let tracker = DeadSessionTracker::new();
let agent_id = "test_agent_engineer";
assert!(tracker.should_retry(agent_id));
tracker.record_attempt(agent_id);
assert!(!tracker.should_retry(agent_id));
for _ in 2..=MAX_RETRIES {
tracker.record_attempt(agent_id);
}
assert!(
!tracker.should_retry(agent_id),
"should be blocked after {MAX_RETRIES} attempts"
);
}
#[test]
fn test_dead_session_tracker_cleanup() {
let tracker = DeadSessionTracker::new();
let agent_id = "test_cleanup_engineer";
tracker.record_attempt(agent_id);
assert!(!tracker.should_retry(agent_id));
tracker.cleanup(agent_id);
assert!(tracker.should_retry(agent_id));
}
#[test]
fn test_dead_session_tracker_exhaustion_is_permanent() {
let tracker = DeadSessionTracker::new();
let agent_id = "test_permanent_engineer";
for _ in 0..MAX_RETRIES {
tracker.record_attempt(agent_id);
}
assert!(!tracker.should_retry(agent_id));
assert!(!tracker.should_retry(agent_id));
assert!(tracker.has_exhausted_retries(agent_id));
}
#[test]
fn test_dead_session_tracker_backoff_and_reset_on_restart() {
let tracker = DeadSessionTracker::new();
let agent_id = "test_backoff_engineer";
assert!(tracker.should_retry(agent_id));
tracker.record_attempt(agent_id);
assert!(!tracker.should_retry(agent_id));
let tracker2 = DeadSessionTracker::new();
assert!(
tracker2.should_retry(agent_id),
"should be fresh after simulated restart"
);
}
}