fix(messaging): make persistence and delivery explicit

This commit is contained in:
xiaoxixi 2026-07-14 11:49:02 +08:00
parent c2d4fc5f09
commit 954bfd1d75
6 changed files with 259 additions and 145 deletions

View File

@ -56,10 +56,14 @@ impl OutboundDispatcher {
if sender.as_ref().is_none_or(mpsc::Sender::is_closed) { if sender.as_ref().is_none_or(mpsc::Sender::is_closed) {
let Some(channel) = self.channel_manager.get_channel(&msg.channel).await else { let Some(channel) = self.channel_manager.get_channel(&msg.channel).await else {
tracing::warn!(channel = %msg.channel, "No channel found for message"); tracing::warn!(channel = %msg.channel, "No channel found for message");
msg.complete_delivery(Err(format!("channel not found: {}", msg.channel)));
continue; continue;
}; };
let (new_sender, receiver) = mpsc::channel(LANE_CAPACITY); let (new_sender, receiver) = mpsc::channel(LANE_CAPACITY);
self.spawn_lane(channel, receiver, msg.channel.clone(), msg.chat_id.clone()); if !self.spawn_lane(channel, receiver, msg.channel.clone(), msg.chat_id.clone()) {
msg.complete_delivery(Err("dispatcher is shutting down".to_string()));
continue;
}
lanes.insert(lane_key.clone(), new_sender.clone()); lanes.insert(lane_key.clone(), new_sender.clone());
sender = Some(new_sender); sender = Some(new_sender);
} }
@ -77,6 +81,7 @@ impl OutboundDispatcher {
capacity = LANE_CAPACITY, capacity = LANE_CAPACITY,
"Outbound lane full; rejecting message instead of blocking other destinations" "Outbound lane full; rejecting message instead of blocking other destinations"
); );
msg.complete_delivery(Err("outbound lane is full".to_string()));
} }
Err(mpsc::error::TrySendError::Closed(msg)) => { Err(mpsc::error::TrySendError::Closed(msg)) => {
// The lane may have expired between the closed check and // The lane may have expired between the closed check and
@ -84,13 +89,25 @@ impl OutboundDispatcher {
lanes.remove(&lane_key); lanes.remove(&lane_key);
let Some(channel) = self.channel_manager.get_channel(&msg.channel).await else { let Some(channel) = self.channel_manager.get_channel(&msg.channel).await else {
tracing::warn!(channel = %msg.channel, "No channel found for message"); tracing::warn!(channel = %msg.channel, "No channel found for message");
msg.complete_delivery(Err(format!("channel not found: {}", msg.channel)));
continue; continue;
}; };
let (new_sender, receiver) = mpsc::channel(LANE_CAPACITY); let (new_sender, receiver) = mpsc::channel(LANE_CAPACITY);
self.spawn_lane(channel, receiver, msg.channel.clone(), msg.chat_id.clone()); if !self.spawn_lane(channel, receiver, msg.channel.clone(), msg.chat_id.clone())
if new_sender.try_send(msg).is_ok() { {
msg.complete_delivery(Err("dispatcher is shutting down".to_string()));
continue;
}
match new_sender.try_send(msg) {
Ok(()) => {
lanes.insert(lane_key, new_sender); lanes.insert(lane_key, new_sender);
} }
Err(error) => {
error.into_inner().complete_delivery(Err(
"outbound lane could not be restarted during shutdown".to_string(),
));
}
}
} }
} }
} }
@ -102,7 +119,7 @@ impl OutboundDispatcher {
mut receiver: mpsc::Receiver<OutboundMessage>, mut receiver: mpsc::Receiver<OutboundMessage>,
channel_name: String, channel_name: String,
chat_id: String, chat_id: String,
) { ) -> bool {
self.task_supervisor.spawn( self.task_supervisor.spawn(
format!("outbound-lane:{channel_name}:{chat_id}"), format!("outbound-lane:{channel_name}:{chat_id}"),
async move { async move {
@ -111,7 +128,8 @@ impl OutboundDispatcher {
Ok(Some(msg)) => msg, Ok(Some(msg)) => msg,
Ok(None) | Err(_) => break, Ok(None) | Err(_) => break,
}; };
if let Err(error) = Self::send_with_retry(&*channel, msg).await { let result = Self::send_with_retry(&*channel, &msg).await;
if let Err(error) = &result {
tracing::error!( tracing::error!(
channel = %channel_name, channel = %channel_name,
chat_id = %chat_id, chat_id = %chat_id,
@ -119,14 +137,15 @@ impl OutboundDispatcher {
"Failed to send message after retries" "Failed to send message after retries"
); );
} }
msg.complete_delivery(result.map_err(|error| error.to_string()));
} }
}, },
); )
} }
async fn send_with_retry( async fn send_with_retry(
channel: &dyn Channel, channel: &dyn Channel,
msg: OutboundMessage, msg: &OutboundMessage,
) -> Result<(), ChannelError> { ) -> Result<(), ChannelError> {
const DELAYS: &[u64] = &[1, 2, 4]; const DELAYS: &[u64] = &[1, 2, 4];
@ -201,6 +220,7 @@ mod tests {
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: HashMap::new(), metadata: HashMap::new(),
delivery: None,
} }
} }
@ -247,4 +267,49 @@ mod tests {
task.abort(); task.abort();
supervisor.shutdown(Duration::from_secs(1)).await; supervisor.shutdown(Duration::from_secs(1)).await;
} }
#[tokio::test]
async fn confirmed_delivery_reports_missing_channel() {
let bus = MessageBus::new(8);
let manager = ChannelManager::with_bus(
Arc::new(crate::channels::CliChatChannel::new()),
bus.clone(),
);
let supervisor = TaskSupervisor::new();
let dispatcher = OutboundDispatcher::new(bus.clone(), manager, supervisor.clone());
let task = tokio::spawn(async move { dispatcher.run().await });
let mut message = outbound("missing", "not delivered");
message.channel = "missing".to_string();
let error = bus.deliver_outbound(message).await.unwrap_err();
assert!(matches!(error, crate::bus::BusError::DeliveryFailed(_)));
task.abort();
supervisor.shutdown(Duration::from_secs(1)).await;
}
#[tokio::test]
async fn confirmed_delivery_waits_for_channel_send() {
let bus = MessageBus::new(8);
let manager = ChannelManager::with_bus(
Arc::new(crate::channels::CliChatChannel::new()),
bus.clone(),
);
let channel = Arc::new(RecordingChannel {
sent: Mutex::new(Vec::new()),
notify: Notify::new(),
});
manager.register_channel("recording", channel.clone()).await;
let supervisor = TaskSupervisor::new();
let dispatcher = OutboundDispatcher::new(bus.clone(), manager, supervisor.clone());
let task = tokio::spawn(async move { dispatcher.run().await });
bus.deliver_outbound(outbound("confirmed", "delivered"))
.await
.unwrap();
assert_eq!(channel.sent.lock().await.as_slice(), &["delivered"]);
task.abort();
supervisor.shutdown(Duration::from_secs(1)).await;
}
} }

View File

@ -276,6 +276,15 @@ pub struct OutboundMessage {
pub reply_to: Option<String>, pub reply_to: Option<String>,
pub media: Vec<MediaItem>, pub media: Vec<MediaItem>,
pub metadata: HashMap<String, String>, pub metadata: HashMap<String, String>,
pub(crate) delivery: Option<tokio::sync::watch::Sender<Option<Result<(), String>>>>,
}
impl OutboundMessage {
pub(crate) fn complete_delivery(&self, result: Result<(), String>) {
if let Some(delivery) = &self.delivery {
delivery.send_replace(Some(result));
}
}
} }
// ============================================================================ // ============================================================================

View File

@ -68,6 +68,25 @@ impl MessageBus {
.map_err(|_| BusError::Closed) .map_err(|_| BusError::Closed)
} }
/// Publish an outbound message and wait for the dispatcher to report the
/// actual channel delivery result.
pub async fn deliver_outbound(&self, mut msg: OutboundMessage) -> Result<(), BusError> {
let (delivery_tx, mut delivery_rx) = tokio::sync::watch::channel(None);
msg.delivery = Some(delivery_tx);
self.publish_outbound(msg).await?;
tokio::time::timeout(std::time::Duration::from_secs(120), async {
loop {
delivery_rx.changed().await.map_err(|_| BusError::Closed)?;
if let Some(result) = delivery_rx.borrow().clone() {
return result.map_err(BusError::DeliveryFailed);
}
}
})
.await
.map_err(|_| BusError::DeliveryTimedOut)?
}
/// Consume an outbound message (Dispatcher -> Bus) /// Consume an outbound message (Dispatcher -> Bus)
pub async fn consume_outbound(&self) -> Option<OutboundMessage> { pub async fn consume_outbound(&self) -> Option<OutboundMessage> {
self.outbound_rx.lock().await.recv().await self.outbound_rx.lock().await.recv().await
@ -95,12 +114,16 @@ impl MessageBus {
#[derive(Debug)] #[derive(Debug)]
pub enum BusError { pub enum BusError {
Closed, Closed,
DeliveryFailed(String),
DeliveryTimedOut,
} }
impl std::fmt::Display for BusError { impl std::fmt::Display for BusError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
BusError::Closed => write!(f, "Bus channel closed"), BusError::Closed => write!(f, "Bus channel closed"),
BusError::DeliveryFailed(error) => write!(f, "Outbound delivery failed: {error}"),
BusError::DeliveryTimedOut => write!(f, "Outbound delivery confirmation timed out"),
} }
} }
} }

View File

@ -650,6 +650,7 @@ mod tests {
reply_to: None, reply_to: None,
media: Vec::new(), media: Vec::new(),
metadata: Default::default(), metadata: Default::default(),
delivery: None,
}) })
.await .await
.unwrap(); .unwrap();

View File

@ -226,6 +226,7 @@ impl GatewayState {
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: inbound.forwarded_metadata, metadata: inbound.forwarded_metadata,
delivery: None,
}; };
if let Err(e) = bus.publish_outbound(outbound).await { if let Err(e) = bus.publish_outbound(outbound).await {
tracing::error!(error = %e, "Failed to publish outbound"); tracing::error!(error = %e, "Failed to publish outbound");
@ -239,6 +240,7 @@ impl GatewayState {
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: inbound.forwarded_metadata, metadata: inbound.forwarded_metadata,
delivery: None,
}; };
if let Err(e) = bus.publish_outbound(outbound).await { if let Err(e) = bus.publish_outbound(outbound).await {
tracing::error!(error = %e, "Failed to publish outbound"); tracing::error!(error = %e, "Failed to publish outbound");

View File

@ -98,6 +98,9 @@ pub struct Session {
/// not overwrite a session that was changed by a command such as /clear or /// not overwrite a session that was changed by a command such as /clear or
/// /delete while the slow work was in flight. /// /delete while the slow work was in flight.
state_version: u64, state_version: u64,
/// Serializes durable mutations while allowing the session state mutex to
/// be released during SQLite I/O.
persistence_lock: Arc<Mutex<()>>,
} }
/// A task to be processed by the per-session agent worker /// A task to be processed by the per-session agent worker
@ -171,6 +174,7 @@ impl Session {
current_cancel: None, current_cancel: None,
worker_generation: 0, worker_generation: 0,
state_version: 0, state_version: 0,
persistence_lock: Arc::new(Mutex::new(())),
}) })
} }
@ -346,6 +350,7 @@ impl Session {
current_cancel: None, current_cancel: None,
worker_generation: 0, worker_generation: 0,
state_version: 0, state_version: 0,
persistence_lock: Arc::new(Mutex::new(())),
}) })
} }
@ -354,22 +359,6 @@ impl Session {
self.id.to_string() self.id.to_string()
} }
/// 添加消息到历史并持久化到 Storage
/// 如果 `persist` 为 false只更新内存用于 compaction 场景)
pub async fn add_message(
&mut self,
message: ChatMessage,
persist: bool,
) -> Result<(), StorageError> {
let message_id = message.id.clone();
let snapshot = self.add_message_in_memory(message, persist);
if let Err(error) = persist_added_message(snapshot).await {
self.rollback_message_suffix(std::slice::from_ref(&message_id));
return Err(error);
}
Ok(())
}
fn add_message_in_memory( fn add_message_in_memory(
&mut self, &mut self,
message: ChatMessage, message: ChatMessage,
@ -480,30 +469,6 @@ impl Session {
&self.messages &self.messages
} }
/// 清除历史消息
pub fn clear_history(&mut self) {
let len = self.messages.len();
self.messages.clear();
self.seq_counter = 1;
self.total_message_count = 0;
self.message_count = 0;
self.state_version = self.state_version.wrapping_add(1);
#[cfg(debug_assertions)]
tracing::debug!(session_id = %self.id, previous_len = len, "Chat history cleared");
}
/// 重置对话上下文
pub fn reset_context(&mut self) {
let len = self.messages.len();
self.messages.clear();
self.seq_counter = 1;
self.total_message_count = 0;
self.message_count = 0;
self.state_version = self.state_version.wrapping_add(1);
#[cfg(debug_assertions)]
tracing::debug!(session_id = %self.id, previous_len = len, "Chat context reset in memory");
}
pub fn create_user_message(&self, content: &str, media_refs: Vec<MediaRef>) -> ChatMessage { pub fn create_user_message(&self, content: &str, media_refs: Vec<MediaRef>) -> ChatMessage {
if media_refs.is_empty() { if media_refs.is_empty() {
ChatMessage::user(content) ChatMessage::user(content)
@ -535,14 +500,6 @@ impl Session {
message message
} }
/// 将 session 元数据写回 Storage
pub async fn persist_session_meta(&self) -> Result<(), StorageError> {
if let Some((storage, meta)) = self.session_meta_snapshot() {
storage.upsert_session(&meta).await?;
}
Ok(())
}
fn session_meta_snapshot( fn session_meta_snapshot(
&self, &self,
) -> Option<(StdArc<Storage>, crate::storage::session::SessionMeta)> { ) -> Option<(StdArc<Storage>, crate::storage::session::SessionMeta)> {
@ -1079,6 +1036,7 @@ impl SessionManager {
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: std::collections::HashMap::new(), metadata: std::collections::HashMap::new(),
delivery: None,
}; };
let _ = sm_bus.publish_outbound(outbound).await; let _ = sm_bus.publish_outbound(outbound).await;
} }
@ -1701,17 +1659,28 @@ impl SessionManager {
) -> Result<(), AgentError> { ) -> Result<(), AgentError> {
// Update in-memory session // Update in-memory session
let session = self.get_or_create_session(session_id).await?; let session = self.get_or_create_session(session_id).await?;
let persistence_lock = { session.lock().await.persistence_lock.clone() };
let _persistence_guard = persistence_lock.lock().await;
let meta_snapshot = {
let mut session_guard = session.lock().await; let mut session_guard = session.lock().await;
session_guard.title = title.to_string(); session_guard.title = title.to_string();
session_guard session_guard.state_version = session_guard.state_version.wrapping_add(1);
.persist_session_meta() session_guard.session_meta_snapshot()
};
if let Some((storage, meta)) = meta_snapshot {
storage
.upsert_session(&meta)
.await .await
.map_err(|e| AgentError::Other(format!("failed to rename dialog: {}", e)))?; .map_err(|e| AgentError::Other(format!("failed to rename dialog: {}", e)))?;
}
Ok(()) Ok(())
} }
pub async fn delete_dialog(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> { pub async fn delete_dialog(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> {
let session_id_str = session_id.to_string(); let session_id_str = session_id.to_string();
let session = self.get_or_create_session(session_id).await?;
let persistence_lock = { session.lock().await.persistence_lock.clone() };
let _persistence_guard = persistence_lock.lock().await;
// Soft delete from Storage // Soft delete from Storage
self.storage self.storage
@ -1730,6 +1699,9 @@ impl SessionManager {
pub async fn archive_dialog(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> { pub async fn archive_dialog(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> {
let session_id_str = session_id.to_string(); let session_id_str = session_id.to_string();
let session = self.get_or_create_session(session_id).await?;
let persistence_lock = { session.lock().await.persistence_lock.clone() };
let _persistence_guard = persistence_lock.lock().await;
self.storage self.storage
.archive_session(&session_id_str) .archive_session(&session_id_str)
.await .await
@ -1842,8 +1814,6 @@ impl SessionManager {
) -> Result<(), AgentError> { ) -> Result<(), AgentError> {
let unified_id = self.resolve_dialog_id(channel, chat_id).await?; let unified_id = self.resolve_dialog_id(channel, chat_id).await?;
let session = self.get_or_create_session(&unified_id).await?; let session = self.get_or_create_session(&unified_id).await?;
{
let mut guard = session.lock().await;
let source = MessageSource { let source = MessageSource {
kind: SourceKind::SystemNotification, kind: SourceKind::SystemNotification,
from_channel: None, from_channel: None,
@ -1853,11 +1823,9 @@ impl SessionManager {
task_id: task_id.map(|s| s.to_string()), task_id: task_id.map(|s| s.to_string()),
}; };
let msg = ChatMessage::assistant_with_source(content, source); let msg = ChatMessage::assistant_with_source(content, source);
guard append_persisted_messages(&session, vec![msg])
.add_message(msg, true)
.await .await
.map_err(|e| AgentError::Other(format!("persist error: {}", e)))?; .map_err(|e| AgentError::Other(format!("persist error: {}", e)))?;
}
let outbound = OutboundMessage { let outbound = OutboundMessage {
channel: channel.to_string(), channel: channel.to_string(),
@ -1866,9 +1834,10 @@ impl SessionManager {
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: HashMap::new(), metadata: HashMap::new(),
delivery: None,
}; };
self.bus self.bus
.publish_outbound(outbound) .deliver_outbound(outbound)
.await .await
.map_err(|e| AgentError::Other(format!("bus publish error: {}", e)))?; .map_err(|e| AgentError::Other(format!("bus publish error: {}", e)))?;
@ -2014,6 +1983,8 @@ async fn maybe_generate_title_outside_lock(session: Arc<Mutex<Session>>) -> Resu
.map_err(|e| AgentError::Other(format!("LLM call failed: {}", e)))?; .map_err(|e| AgentError::Other(format!("LLM call failed: {}", e)))?;
let title = response.content.trim().to_string(); let title = response.content.trim().to_string();
let persistence_lock = { session.lock().await.persistence_lock.clone() };
let _persistence_guard = persistence_lock.lock().await;
let meta_snapshot = { let meta_snapshot = {
let mut guard = session.lock().await; let mut guard = session.lock().await;
if guard.apply_generated_title(title) { if guard.apply_generated_title(title) {
@ -2033,22 +2004,6 @@ async fn maybe_generate_title_outside_lock(session: Arc<Mutex<Session>>) -> Resu
Ok(()) Ok(())
} }
async fn persist_added_message(
snapshot: Option<MessagePersistSnapshot>,
) -> Result<(), StorageError> {
let Some((storage, session_id, msg_meta, session_meta)) = snapshot else {
return Ok(());
};
storage
.persist_message_batch_with_retry(
&session_id,
std::slice::from_ref(&msg_meta),
&session_meta,
)
.await
}
async fn persist_added_messages( async fn persist_added_messages(
snapshots: Vec<Option<MessagePersistSnapshot>>, snapshots: Vec<Option<MessagePersistSnapshot>>,
) -> Result<(), StorageError> { ) -> Result<(), StorageError> {
@ -2081,6 +2036,32 @@ async fn persist_added_messages(
.await .await
} }
async fn append_persisted_messages(
session: &Arc<Mutex<Session>>,
messages: Vec<ChatMessage>,
) -> Result<(), StorageError> {
if messages.is_empty() {
return Ok(());
}
let persistence_lock = { session.lock().await.persistence_lock.clone() };
let _persistence_guard = persistence_lock.lock().await;
let message_ids: Vec<_> = messages.iter().map(|message| message.id.clone()).collect();
let snapshots = {
let mut guard = session.lock().await;
messages
.into_iter()
.map(|message| guard.add_message_in_memory(message, true))
.collect()
};
if let Err(error) = persist_added_messages(snapshots).await {
session.lock().await.rollback_message_suffix(&message_ids);
return Err(error);
}
Ok(())
}
fn spawn_agent_worker( fn spawn_agent_worker(
mut task_rx: mpsc::Receiver<AgentTask>, mut task_rx: mpsc::Receiver<AgentTask>,
session: Arc<Mutex<Session>>, session: Arc<Mutex<Session>>,
@ -2122,6 +2103,7 @@ fn spawn_agent_worker(
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata, metadata,
delivery: None,
}; };
let _ = bus.publish_outbound(outbound).await; let _ = bus.publish_outbound(outbound).await;
} }
@ -2134,6 +2116,30 @@ fn spawn_agent_worker(
// /stop and other commands are not blocked behind slow I/O or // /stop and other commands are not blocked behind slow I/O or
// LLM-backed compaction. // LLM-backed compaction.
let skills_prompt = skills_loader.build_skills_prompt(); let skills_prompt = skills_loader.build_skills_prompt();
let user_message = {
let guard = session.lock().await;
if guard.worker_generation != worker_gen {
return;
}
let media_refs: Vec<MediaRef> =
task.media.iter().map(MediaItem::to_media_ref).collect();
guard.create_user_message(&task.content, media_refs)
};
if let Err(e) = append_persisted_messages(&session, vec![user_message]).await {
tracing::error!(error = %e, "Failed to persist user message");
let err_outbound = OutboundMessage {
channel: task_chan.clone(),
chat_id: task_cid.clone(),
content: "Failed to save your message, please try again.".to_string(),
reply_to: None,
media: vec![],
metadata: HashMap::new(),
delivery: None,
};
let _ = bus.publish_outbound(err_outbound).await;
continue 'tasks;
}
let (agent, history_raw, mut compressor, base_version, cancel_rx) = { let (agent, history_raw, mut compressor, base_version, cancel_rx) = {
let mut guard = session.lock().await; let mut guard = session.lock().await;
@ -2141,31 +2147,6 @@ fn spawn_agent_worker(
return; // stale worker return; // stale worker
} }
let media_refs: Vec<MediaRef> =
task.media.iter().map(|m| m.to_media_ref()).collect();
let user_message = guard.create_user_message(&task.content, media_refs);
let user_message_id = user_message.id.clone();
let user_persist = guard.add_message_in_memory(user_message, true);
if let Err(e) = persist_added_message(user_persist).await {
guard.rollback_message_suffix(std::slice::from_ref(&user_message_id));
drop(guard);
tracing::error!(error = %e, "Failed to persist user message");
let err_outbound = OutboundMessage {
channel: task_chan.clone(),
chat_id: task_cid.clone(),
content: "Failed to save your message, please try again."
.to_string(),
reply_to: None,
media: vec![],
metadata: HashMap::new(),
};
let _ = bus.publish_outbound(err_outbound).await;
continue 'tasks;
}
if guard.worker_generation != worker_gen {
return;
}
let history_raw = guard.get_history().to_vec(); let history_raw = guard.get_history().to_vec();
let agent = match guard.create_agent_with_notify(notify_tx) { let agent = match guard.create_agent_with_notify(notify_tx) {
@ -2180,6 +2161,7 @@ fn spawn_agent_worker(
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: HashMap::new(), metadata: HashMap::new(),
delivery: None,
}; };
let _ = bus.publish_outbound(err_outbound).await; let _ = bus.publish_outbound(err_outbound).await;
continue 'tasks; continue 'tasks;
@ -2332,6 +2314,7 @@ fn spawn_agent_worker(
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: HashMap::new(), metadata: HashMap::new(),
delivery: None,
}; };
let _ = bus2.publish_outbound(err_outbound).await; let _ = bus2.publish_outbound(err_outbound).await;
return; return;
@ -2390,6 +2373,7 @@ fn spawn_agent_worker(
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: HashMap::new(), metadata: HashMap::new(),
delivery: None,
}; };
let _ = bus2.publish_outbound(err_outbound).await; let _ = bus2.publish_outbound(err_outbound).await;
return; return;
@ -2405,29 +2389,24 @@ fn spawn_agent_worker(
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: HashMap::new(), metadata: HashMap::new(),
delivery: None,
}; };
let _ = bus2.publish_outbound(err_outbound).await; let _ = bus2.publish_outbound(err_outbound).await;
return; return;
} }
}; };
let response = { let response_content = result.final_response.content;
let mut guard = session2.lock().await; let total_tokens = result.total_tokens;
let mut persist_snapshots = Vec::new(); let response =
let mut message_ids = Vec::new(); if let Err(e) = append_persisted_messages(&session2, result.emitted_messages).await {
for msg in result.emitted_messages {
message_ids.push(msg.id.clone());
persist_snapshots.push(guard.add_message_in_memory(msg, true));
}
let sent_count = guard.messages.len();
guard.compressor.set_last_api_info(sent_count, result.total_tokens);
if let Err(e) = persist_added_messages(persist_snapshots).await {
guard.rollback_message_suffix(&message_ids);
tracing::error!(error = %e, "Failed to atomically persist agent turn"); tracing::error!(error = %e, "Failed to atomically persist agent turn");
None None
} else { } else {
Some(result.final_response.content) let mut guard = session2.lock().await;
} let sent_count = guard.messages.len();
guard.compressor.set_last_api_info(sent_count, total_tokens);
Some(response_content)
}; };
let Some(response) = response else { let Some(response) = response else {
@ -2439,6 +2418,7 @@ fn spawn_agent_worker(
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: HashMap::new(), metadata: HashMap::new(),
delivery: None,
}; };
let _ = bus2.publish_outbound(err_outbound).await; let _ = bus2.publish_outbound(err_outbound).await;
return; return;
@ -2455,6 +2435,7 @@ fn spawn_agent_worker(
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: HashMap::new(), metadata: HashMap::new(),
delivery: None,
}; };
let _ = bus2.publish_outbound(outbound).await; let _ = bus2.publish_outbound(outbound).await;
}; };
@ -2533,6 +2514,8 @@ impl SessionManager {
unified_id: &UnifiedSessionId, unified_id: &UnifiedSessionId,
) -> Result<(), AgentError> { ) -> Result<(), AgentError> {
let session = self.get_or_create_session(unified_id).await?; let session = self.get_or_create_session(unified_id).await?;
let persistence_lock = { session.lock().await.persistence_lock.clone() };
let _persistence_guard = persistence_lock.lock().await;
let (storage, session_id, meta_snapshot) = { let (storage, session_id, meta_snapshot) = {
let mut session_guard = session.lock().await; let mut session_guard = session.lock().await;
// Clear in-memory // Clear in-memory
@ -2618,15 +2601,11 @@ impl OutboundMessenger for SessionManager {
format!("[message from {}] \n{}", origin, content) format!("[message from {}] \n{}", origin, content)
}; };
// Write source-tagged assistant message to target session history // Write the same text and media delivered to the target into history.
{ let msg = outbound_history_message(marked_content.clone(), source, &media);
let mut guard = session.lock().await; append_persisted_messages(&session, vec![msg])
let msg = ChatMessage::assistant_with_source(marked_content.clone(), source);
guard
.add_message(msg, true)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
}
// Restore active dialog if source and target share channel:chat_id but differ in dialog_id // Restore active dialog if source and target share channel:chat_id but differ in dialog_id
if let Some(ref origin_id) = origin_id { if let Some(ref origin_id) = origin_id {
@ -2653,9 +2632,10 @@ impl OutboundMessenger for SessionManager {
reply_to: None, reply_to: None,
media, media,
metadata: HashMap::new(), metadata: HashMap::new(),
delivery: None,
}; };
self.bus self.bus
.publish_outbound(outbound) .deliver_outbound(outbound)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@ -2663,6 +2643,16 @@ impl OutboundMessenger for SessionManager {
} }
} }
fn outbound_history_message(
content: impl Into<String>,
source: MessageSource,
media: &[MediaItem],
) -> ChatMessage {
let mut message = ChatMessage::assistant_with_source(content, source);
message.media_refs = media.iter().map(MediaItem::to_media_ref).collect();
message
}
fn format_task_notification( fn format_task_notification(
task_id: &str, task_id: &str,
status: &crate::agent::TaskStatus, status: &crate::agent::TaskStatus,
@ -2680,3 +2670,27 @@ fn format_task_notification(
crate::agent::TaskStatus::TimedOut => format!("📋 后台任务超时\n\n任务 ID: {}", task_id), crate::agent::TaskStatus::TimedOut => format!("📋 后台任务超时\n\n任务 ID: {}", task_id),
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn outbound_history_preserves_delivered_media() {
let source = MessageSource {
kind: SourceKind::CrossChannel,
from_channel: Some("cli_chat".into()),
from_session: Some("cli_chat:source:dialog".into()),
from_user_id: None,
system_name: None,
task_id: None,
};
let media = vec![MediaItem::new("/tmp/report.pdf", "file")];
let message = outbound_history_message("report", source, &media);
assert_eq!(message.media_refs.len(), 1);
assert_eq!(message.media_refs[0].path, "/tmp/report.pdf");
assert_eq!(message.media_refs[0].media_type, "file");
}
}