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, last_content: Option<&str>) -> bool {
match last_role {
ChatRole::User | ChatRole::Tool => true,
ChatRole::Assistant => last_content
.is_some_and(|content| super::is_dangling_tool_call_frame(last_role, content)),
ChatRole::System => false,
}
}
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; };
let tail_content = if last_role == ChatRole::Assistant {
crate::session::store()
.get_last_message_content(agent_id)
.await
} else {
None
};
if !is_recovery_candidate(last_role, tail_content.as_deref()) {
DEAD_SESSION_TRACKER.cleanup(agent_id);
continue;
}
let needs_pause_guard = last_role == ChatRole::Assistant;
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;
};
if needs_pause_guard {
match crate::workspace::get_by_name(&ctx.workspace_name).await {
Ok(Some(ws)) if ws.paused => {
tracing::debug!(
agent_id = %agent_id,
workspace = %ctx.workspace_name,
"Dead session recovery: workspace paused — skipping"
);
continue;
}
Ok(_) => {}
Err(e) => {
tracing::warn!(
agent_id = %agent_id,
error = %e,
"Dead session recovery: workspace lookup failed — skipping this cycle"
);
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::*;
use crate::Tool;
#[test]
fn test_is_excluded_agent_id_cases() {
struct Case {
name: &'static str,
id: String,
excluded: bool,
}
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",
);
}
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, None));
assert!(is_recovery_candidate(ChatRole::Tool, None));
assert!(!is_recovery_candidate(
ChatRole::Assistant,
Some("plain assistant reply")
));
assert!(!is_recovery_candidate(
ChatRole::Assistant,
Some(r#"{"answer": 42}"#)
));
let frame = crate::providers::reasoning::assistant_replay_payload(
Some(""),
&[crate::ToolCall {
id: "call_frame".into(),
name: "read".into(),
arguments: serde_json::json!({"path": "a.txt"}),
}],
None,
)
.to_string();
assert!(is_recovery_candidate(ChatRole::Assistant, Some(&frame)));
assert!(!is_recovery_candidate(
ChatRole::Assistant,
Some(r#"{"content":"","tool_calls":[]}"#)
));
assert!(!is_recovery_candidate(
ChatRole::Assistant,
Some("not json at all")
));
assert!(!is_recovery_candidate(ChatRole::System, None));
}
#[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"
);
}
#[expect(clippy::too_many_lines)] async fn seed_dangling_durable_session(agent_id: &str, ws: &crate::Workspace) -> String {
let conn = &crate::session::store().conn;
let frame = crate::providers::reasoning::assistant_replay_payload(
Some(""),
&[crate::ToolCall {
id: "call_analyze_e2e".to_string(),
name: "analyze".to_string(),
arguments: serde_json::json!({"analyze": "analyze this"}),
}],
None,
)
.to_string();
crate::session::store()
.append_messages(
agent_id,
&[
crate::ChatMessage::user("run the analysis"),
crate::ChatMessage::assistant(frame),
],
false,
Some(("gui", "e2e_user", ws.name.as_str(), "engineer")),
)
.await
.unwrap();
let past = (chrono::Utc::now() - chrono::Duration::minutes(10)).to_rfc3339();
conn.execute(
"UPDATE session_metadata SET last_activity = ?1 WHERE agent_id = ?2",
crate::db::params![past, agent_id],
)
.await
.unwrap();
crate::shutdown::drain_begin();
let tool = crate::tools::analyze::AnalyzeTool::new(
crate::tools::analyze::DispatchMode::Sync,
crate::Role::Engineer,
);
let res = crate::agent::CURRENT_TOOL_AGENT_ID
.scope(Some(agent_id.to_string()), async {
tool.execute(ws, serde_json::json!({"analyze": "analyze this"}))
.await
})
.await;
crate::shutdown::drain_clear();
let err = res.expect_err("drain must cut the sync analyze dispatch");
assert!(
err.downcast_ref::<crate::tools::CallSuspended>().is_some(),
"CallSuspended carrier expected: {err:#}"
);
let jobs = conn
.query(
"SELECT id, status, caller_agent_id FROM jobs WHERE caller_agent_id = ?1 AND kind = 'analyze'",
crate::db::params![agent_id],
)
.await
.unwrap();
assert_eq!(jobs.len(), 1, "one launched analyze job");
assert_eq!(jobs[0].get::<String>(1).unwrap(), "launched");
assert_eq!(
jobs[0].get::<String>(2).unwrap(),
agent_id,
"job is caller-owned by the session pin"
);
let job_id = jobs[0].get::<String>(0).unwrap();
let session_rows = conn
.query(
"SELECT role FROM sessions WHERE agent_id = ?1 ORDER BY id",
crate::db::params![agent_id],
)
.await
.unwrap();
let roles: Vec<String> = session_rows
.iter()
.map(|r| r.get::<String>(0).unwrap())
.collect();
assert_eq!(
roles,
vec!["user", "assistant"],
"frame persisted with NO tool-result row: {roles:?}"
);
let roster = crate::jobs::list_agents_for_job(conn, &job_id)
.await
.unwrap();
assert!(
roster.len() >= 2,
"analyze round spawns multiple analysts: {}",
roster.len()
);
for (i, row) in roster.iter().enumerate() {
let outcome = if i == 0 { "ANALYST_RAW" } else { "" };
crate::jobs::write_agent_outcome(
conn,
&job_id,
&row.agent_id,
crate::jobs::RowStatus::Done,
Some(outcome),
)
.await
.unwrap();
}
job_id
}
#[tokio::test]
async fn recovery_resumes_dangling_durable_job_end_to_end() {
let fake = std::sync::Arc::new(
crate::util::test::FakeProvider::new()
.ok("Recovery round: analysis resumed and complete."),
);
let _seam = crate::util::test::install_retry_seam_dyn(fake.clone());
crate::util::test::init_management_test_stores().await;
let _ = crate::CHANNEL_REGISTRY.set(crate::ChannelRegistry::default());
let ws = crate::util::test::create_test_workspace(
"/tmp/dead_session_e2e_ws",
"dead_session_e2e_ws",
)
.await;
let agent_id = format!("e2e_user_{}_engineer", ws.name);
let conn = &crate::session::store().conn;
let job_id = seed_dangling_durable_session(&agent_id, &ws).await;
recover_dead_sessions().await.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
loop {
let jobs = conn
.query(
"SELECT id FROM jobs WHERE id = ?1",
crate::db::params![job_id.clone()],
)
.await
.unwrap();
let tail_role = crate::session::store()
.get_last_message_role(&agent_id)
.await;
let tail_content = crate::session::store()
.get_last_message_content(&agent_id)
.await;
if jobs.is_empty()
&& tail_role == Some(crate::ChatRole::Assistant)
&& tail_content
.as_deref()
.is_some_and(|c| c.contains("Recovery round"))
{
break;
}
assert!(
std::time::Instant::now() < deadline,
"recovery did not complete within 60s: jobs={jobs:?} \
tail_role={tail_role:?} tail_has_reply={}",
tail_content
.as_deref()
.is_some_and(|c| c.contains("Recovery round")),
);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
let last2 = conn
.query(
"SELECT role, content FROM sessions WHERE agent_id = ?1 ORDER BY id DESC LIMIT 2",
crate::db::params![agent_id],
)
.await
.unwrap();
assert_eq!(last2.len(), 2, "tail has tool-result + reply: {last2:?}");
let reply_row = &last2[0];
let tool_row = &last2[1];
assert_eq!(tool_row.get::<String>(0).unwrap(), "tool");
let payload: crate::ToolResultPayload =
serde_json::from_str(&tool_row.get::<String>(1).unwrap()).unwrap();
assert_eq!(payload.tool_call_id, "call_analyze_e2e");
assert!(
payload.content.contains("ANALYST_RAW"),
"checkpointed outcome recorded: {}",
payload.content
);
assert!(
reply_row
.get::<String>(1)
.unwrap()
.contains("Recovery round"),
"recovery round reply persisted"
);
assert_eq!(
fake.request_fingerprints.lock().unwrap().len(),
1,
"durable resume sub-phase was LLM-free; the recovery round called the model once"
);
let pending_jobs = conn
.query(
"SELECT id FROM pending_jobs WHERE id = ?1",
crate::db::params![job_id.clone()],
)
.await
.unwrap();
assert!(pending_jobs.is_empty(), "no envelope pending after resume");
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn live_running_session_is_not_recovered() {
let _lock = crate::util::test::retry_tests_lock();
crate::util::test::init_test_stores().await;
let ws = crate::util::test::create_test_workspace(
"/tmp/dead_session_live_ws",
"dead_session_live_ws",
)
.await;
let agent_id = format!("e2e_user_{}_engineer", ws.name);
let conn = &crate::session::store().conn;
let job_id = seed_dangling_durable_session(&agent_id, &ws).await;
let generation = AGENT_REGISTRY.register(
agent_id.clone(),
"engineer".into(),
None,
&ws,
"live".into(),
tokio_util::sync::CancellationToken::new(),
None,
None,
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
);
recover_dead_sessions().await.unwrap();
let jobs = conn
.query(
"SELECT status FROM jobs WHERE id = ?1",
crate::db::params![job_id.clone()],
)
.await
.unwrap();
assert_eq!(jobs.len(), 1, "job must not be terminalized");
assert_eq!(jobs[0].get::<String>(0).unwrap(), "launched");
assert!(
DEAD_SESSION_TRACKER.should_retry(&agent_id),
"no recovery attempt recorded for a live session"
);
AGENT_REGISTRY.deregister(&agent_id, generation);
conn.execute(
"DELETE FROM sessions WHERE agent_id = ?1",
crate::db::params![agent_id.clone()],
)
.await
.unwrap();
conn.execute(
"DELETE FROM session_metadata WHERE agent_id = ?1",
crate::db::params![agent_id],
)
.await
.unwrap();
for row in conn
.query(
"SELECT agent_id FROM agents WHERE job_id = ?1",
crate::db::params![job_id.clone()],
)
.await
.unwrap()
{
let a_id: String = row.get(0).unwrap();
conn.execute(
"DELETE FROM agents WHERE job_id = ?1 AND agent_id = ?2",
crate::db::params![job_id.clone(), a_id],
)
.await
.unwrap();
}
conn.execute(
"DELETE FROM jobs WHERE id = ?1",
crate::db::params![job_id.clone()],
)
.await
.unwrap();
}
#[tokio::test]
#[expect(clippy::await_holding_lock)] async fn paused_workspace_dangling_frame_is_not_recovered() {
let _lock = crate::util::test::retry_tests_lock();
crate::util::test::init_management_test_stores().await;
let ws = crate::util::test::create_test_workspace(
"/tmp/dead_session_paused_ws",
"dead_session_paused_ws",
)
.await;
let agent_id = format!("e2e_user_{}_engineer", ws.name);
let conn = &crate::session::store().conn;
let job_id = seed_dangling_durable_session(&agent_id, &ws).await;
conn.execute(
"UPDATE workspaces SET paused = 1 WHERE name = ?1",
crate::db::params![ws.name],
)
.await
.unwrap();
recover_dead_sessions().await.unwrap();
let jobs = conn
.query(
"SELECT status FROM jobs WHERE id = ?1",
crate::db::params![job_id.clone()],
)
.await
.unwrap();
assert_eq!(jobs.len(), 1, "job must not be terminalized");
assert_eq!(jobs[0].get::<String>(0).unwrap(), "launched");
assert!(
DEAD_SESSION_TRACKER.should_retry(&agent_id),
"no recovery attempt recorded for a paused workspace"
);
}
}