use std::sync::Arc;
use uuid::Uuid;
use crate::brain::agent::AgentService;
use crate::brain::agent::service::boot_report;
use crate::channels::transport_ready::await_transport;
use crate::tui::events::TuiEvent;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ResumeSendOutcome {
Sent,
SurfacedTui,
SendFailed,
TransportGone,
NoAddress,
BadAddress,
Unsupported,
}
impl ResumeSendOutcome {
pub(crate) fn counts_as_delivered(self) -> bool {
matches!(self, Self::Sent | Self::SurfacedTui)
}
}
pub(crate) fn resume_notice_text(session_id: Uuid) -> String {
format!(
"🔁 Restart recovery: continuing session {}'s interrupted turn. The answer will land here when done.",
&session_id.simple().to_string()[..8]
)
}
pub(crate) struct ResumePresence {
tx: tokio::sync::mpsc::UnboundedSender<TuiEvent>,
session_id: Uuid,
}
impl ResumePresence {
pub(crate) fn start(
tx: &tokio::sync::mpsc::UnboundedSender<TuiEvent>,
session_id: Uuid,
channel: &str,
) -> Option<Self> {
if channel == "tui" {
return None;
}
let _ = tx.send(TuiEvent::ChannelProcessingStarted(session_id));
Some(Self {
tx: tx.clone(),
session_id,
})
}
}
impl Drop for ResumePresence {
fn drop(&mut self) {
let _ = self
.tx
.send(TuiEvent::ChannelProcessingFinished(self.session_id));
}
}
pub(crate) fn chunks_for(channel: &str, text: &str) -> Vec<String> {
#[cfg(feature = "discord")]
if channel == "discord" {
return crate::channels::discord::handler::split_message(text, DISCORD_MAX);
}
let _ = channel;
vec![text.to_string()]
}
#[derive(Clone)]
pub(crate) struct ResumeTransports {
#[cfg(feature = "discord")]
pub(crate) discord: Arc<crate::channels::discord::DiscordState>,
#[cfg(feature = "whatsapp")]
pub(crate) whatsapp: Arc<crate::channels::whatsapp::WhatsAppState>,
#[cfg(feature = "slack")]
pub(crate) slack: Arc<crate::channels::slack::SlackState>,
}
#[cfg(feature = "discord")]
pub(crate) const DISCORD_MAX: usize = 2000;
pub(crate) async fn resume_send_text(
channel: &str,
session_id: Uuid,
target: &str,
text: &str,
transports: &ResumeTransports,
) -> ResumeSendOutcome {
#[cfg(not(any(feature = "discord", feature = "whatsapp", feature = "slack")))]
let _ = (&target, &text, &transports);
match channel {
#[cfg(feature = "discord")]
"discord" => {
let Ok(ch_id) = target.parse::<u64>() else {
tracing::error!(
"[boot-resume] discord: chat id {target:?} for session {session_id} did not parse; answer not delivered"
);
return ResumeSendOutcome::BadAddress;
};
let Some(http) =
await_transport("discord", session_id, || transports.discord.http()).await
else {
return ResumeSendOutcome::TransportGone;
};
let chan = serenity::model::id::ChannelId::new(ch_id);
let chunks = chunks_for(channel, text);
let total = chunks.len();
let mut send_failed = false;
for (index, chunk) in chunks.into_iter().enumerate() {
if let Err(e) = chan.say(&http, &chunk).await {
send_failed = true;
if index == 0 {
tracing::error!(error = %e, "[boot-resume] discord: first of {total} chunks failed, the whole answer was lost for session {session_id}");
} else {
tracing::warn!(error = %e, "[boot-resume] discord: chunk {} of {total} failed for session {session_id}", index + 1);
}
}
}
if send_failed {
ResumeSendOutcome::SendFailed
} else {
ResumeSendOutcome::Sent
}
}
#[cfg(feature = "whatsapp")]
"whatsapp" => {
let Ok(jid) = target.parse::<wacore_binary::jid::Jid>() else {
tracing::error!(
"[boot-resume] whatsapp: chat id {target:?} for session {session_id} did not parse; answer not delivered"
);
return ResumeSendOutcome::BadAddress;
};
let Some(client) =
await_transport("whatsapp", session_id, || transports.whatsapp.client()).await
else {
return ResumeSendOutcome::TransportGone;
};
let msg = waproto::whatsapp::Message {
conversation: Some(text.to_string()),
..Default::default()
};
match client.send_message(jid, msg).await {
Ok(_) => ResumeSendOutcome::Sent,
Err(e) => {
tracing::warn!(error = %e, "[boot-resume] whatsapp: send_message failed for session {session_id}");
ResumeSendOutcome::SendFailed
}
}
}
#[cfg(feature = "slack")]
"slack" => {
let Some((token_val, client)) = await_transport("slack", session_id, || async {
match (
transports.slack.bot_token().await,
transports.slack.client().await,
) {
(Some(token), Some(client)) => Some((token, client)),
_ => None,
}
})
.await
else {
return ResumeSendOutcome::TransportGone;
};
let api_token = slack_morphism::prelude::SlackApiToken::new(
slack_morphism::prelude::SlackApiTokenValue::from(token_val),
);
let session = client.open_session(&api_token);
let req = slack_morphism::prelude::SlackApiChatPostMessageRequest::new(
target.to_string().into(),
slack_morphism::prelude::SlackMessageContent::new().with_text(text.to_string()),
);
match session.chat_post_message(&req).await {
Ok(_) => ResumeSendOutcome::Sent,
Err(e) => {
tracing::warn!(error = %e, "[boot-resume] slack: chat_post_message failed for session {session_id}");
ResumeSendOutcome::SendFailed
}
}
}
other => {
tracing::warn!(
"[boot-resume] no recovery routing for channel {other} (session {session_id}); answer saved to DB only"
);
ResumeSendOutcome::Unsupported
}
}
}
pub(crate) async fn resume_delivery_task(
agent: Arc<AgentService>,
ev_tx: tokio::sync::mpsc::UnboundedSender<TuiEvent>,
transports: ResumeTransports,
token: tokio_util::sync::CancellationToken,
channel: String,
channel_chat_id: Option<String>,
session_id: Uuid,
) {
let _presence = ResumePresence::start(&ev_tx, session_id, &channel);
let notice_handle = if channel != "tui" && channel != "telegram" {
channel_chat_id.clone().map(|cid| {
let ch = channel.clone();
let t = transports.clone();
tokio::spawn(async move {
let outcome =
resume_send_text(&ch, session_id, &cid, &resume_notice_text(session_id), &t)
.await;
if outcome != ResumeSendOutcome::Sent {
tracing::warn!(
"[boot-resume] resuming notice not delivered for session {session_id} on {ch}: {outcome:?}"
);
}
outcome
})
})
} else {
None
};
let prompt = "[System: A restart just occurred while you were \
processing a request. Read the conversation context and continue \
where you left off naturally. Do not mention the restart or \
any interruption — just pick up seamlessly.]"
.to_string();
match agent
.resume_interrupted_turn(
session_id,
prompt,
None,
Some(token),
None,
None,
&channel,
channel_chat_id.as_deref(),
)
.await
{
Ok(response) => {
tracing::info!(
"Resume completed for session {} ({}): {} chars",
session_id,
channel,
response.content.len()
);
if let Some(handle) = notice_handle {
let _ = handle.await;
}
if let Some(mut agent_status) =
crate::brain::agent::service::work_status::WorkStatus::find_agent_by_session(
&session_id.to_string(),
)
{
boot_report::record_parked();
crate::brain::agent::service::restart_recovery::deliver_revived_agent_outcome(
&mut agent_status,
Ok(&response.content),
);
return;
}
let outcome = if channel == "tui" {
match ev_tx.send(TuiEvent::ResponseComplete {
session_id,
response,
}) {
Ok(()) => ResumeSendOutcome::SurfacedTui,
Err(_) => ResumeSendOutcome::SendFailed,
}
} else {
match &channel_chat_id {
Some(cid) => {
resume_send_text(&channel, session_id, cid, &response.content, &transports)
.await
}
None => {
tracing::error!(
"[boot-resume] response for session {session_id} (channel {channel}) has no chat id on its pending row; answer not delivered"
);
ResumeSendOutcome::NoAddress
}
}
};
if outcome.counts_as_delivered() {
boot_report::record_delivered();
} else {
tracing::error!(
"[boot-resume] session {session_id} ({channel}): answer generated but not delivered: {outcome:?}"
);
boot_report::record_failed();
}
}
Err(e) => {
tracing::error!("Resume failed for session {}: {}", session_id, e);
boot_report::record_failed();
if let Some(mut agent_status) =
crate::brain::agent::service::work_status::WorkStatus::find_agent_by_session(
&session_id.to_string(),
)
{
crate::brain::agent::service::restart_recovery::deliver_revived_agent_outcome(
&mut agent_status,
Err(&e.to_string()),
);
return;
}
if channel == "tui" {
let _ = ev_tx.send(TuiEvent::Error {
session_id,
message: e.to_string(),
});
}
}
}
}