oudecheng 14d903e067 fix: Agent 执行与显示层解耦,修复锁屏冻结根因
浏览器锁屏导致 WebSocket 半死,ws_sender.send().await 永久阻塞,
级联阻塞 dispatcher → MessageBus → Agent Loop,后端停止执行直到解锁。

基于第一性原理建立"执行-显示解耦"原则:agent 执行只依赖 SQLite
持久化,实时广播是可丢弃的最佳努力通道。

核心改动:
- MessageBus::publish_outbound 由 send().await 改为 try_send(),
  bus 满时丢弃消息并告警,agent 不再被显示层阻塞
- WebSocket writer task 包裹 30s 超时,使用每连接独立的
  CancellationToken(非共享 CliChannel 级 token),避免一个连接
  超时关闭所有连接;writer 退出时 cancel 通知主 loop 退出
- CliChannel::send 由 send().await 改为 try_send(),避免 dispatcher
  单线程被卡住的 writer 阻塞 37s
- 全仓 13 处 publish_outbound 调用统一区分 Dropped(warn)/Closed(error)
- scheduler 3 处 ? 改为 warn,避免 Dropped 触发 misfire 重试风暴
- 前端 WebSocket 添加 25s ping + 指数退避重连(3s→60s封顶)
- 前端 session_list 区分重连恢复/首次连接,重连时保留 messages
  并刷新 topic 列表

经五轮对抗性审查验证,修复了共享 cancel token、dispatcher 阻塞、
load_chat_messages 跨 topic 污染、原 session 删除后状态不一致等
回归问题。

同时升级版本号至 0.3.0 并更新 CHANGELOG。

验证:cargo clippy --all-targets --all-features ✓
      npm run build ✓ | useChat.test.ts 13 passed ✓
2026-08-04 10:55:52 +08:00

203 lines
6.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{RwLock, mpsc};
use tokio_util::sync::CancellationToken;
use crate::bus::{MessageBus, OutboundMessage};
use crate::protocol::WsOutbound;
use crate::protocol::ws_adapter::ws_outbound_from_outbound_message;
use super::base::{Channel, ChannelError};
#[derive(Clone)]
struct CliConnection {
connection_id: String,
sender: mpsc::Sender<WsOutbound>,
}
#[derive(Clone)]
pub struct CliChannel {
connections: Arc<RwLock<HashMap<String, CliConnection>>>,
/// 全局关闭信号stop() 时 cancel所有 handle_socket 同时收到信号
shutdown_token: CancellationToken,
}
impl CliChannel {
pub fn new() -> Self {
Self {
connections: Arc::new(RwLock::new(HashMap::new())),
shutdown_token: CancellationToken::new(),
}
}
/// 注册连接,返回 CancellationToken 供 handle_socket 监听
///
/// 注意:此方法不会向被替换的旧连接发送关闭信号。
/// 关闭信号仅由 stop() 统一触发,避免正常消息处理时误关连接。
pub async fn register_connection(
&self,
session_id: impl Into<String>,
connection_id: impl Into<String>,
sender: mpsc::Sender<WsOutbound>,
) -> CancellationToken {
self.connections.write().await.insert(
session_id.into(),
CliConnection {
connection_id: connection_id.into(),
sender,
},
);
self.shutdown_token.clone()
}
pub async fn unregister_connection(&self, connection_id: &str) {
self.connections
.write()
.await
.retain(|_, connection| connection.connection_id != connection_id);
}
}
impl Default for CliChannel {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Channel for CliChannel {
fn name(&self) -> &str {
"websocket"
}
fn is_running(&self) -> bool {
true
}
async fn start(&self, _bus: Arc<MessageBus>) -> Result<(), ChannelError> {
Ok(())
}
async fn stop(&self) -> Result<(), ChannelError> {
// 先 cancel 所有 handle_socket再清空 connections
self.shutdown_token.cancel();
let count = self.connections.read().await.len();
self.connections.write().await.clear();
tracing::info!(
connection_count = count,
"CliChannel stopped, all connections signaled to close"
);
Ok(())
}
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
let connection = self.connections.read().await.get(&msg.chat_id).cloned();
let Some(connection) = connection else {
return Err(ChannelError::SendError(format!(
"No active CLI connection for session {}",
msg.chat_id
)));
};
// 使用 try_send 避免阻塞 dispatcher——dispatcher 是单线程顺序处理,
// 若 writer task 卡在 ws_sender.send() 上send().await 会阻塞,
// 导致所有连接的实时消息被阻塞。try_send 满时立即返回错误,
// dispatcher 记录后继续处理下一条消息。
for outbound in ws_outbound_from_outbound_message(&msg) {
connection
.sender
.try_send(outbound)
.map_err(|_| ChannelError::SendError("CLI websocket sender closed or full".to_string()))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bus::OutboundMessage;
#[tokio::test]
async fn test_cli_channel_sends_to_registered_session() {
let channel = CliChannel::new();
let (sender, mut receiver) = mpsc::channel(4);
let _token = channel
.register_connection("session-1", "conn-1", sender)
.await;
channel
.send(OutboundMessage::assistant(
"cli",
"session-1",
None, // session_id
"hello",
None,
HashMap::new(),
))
.await
.unwrap();
let outbound = receiver.recv().await.unwrap();
assert!(matches!(outbound, WsOutbound::AssistantResponse { .. }));
}
#[tokio::test]
async fn test_cli_channel_unregisters_connection_sessions() {
let channel = CliChannel::new();
let (sender, _receiver) = mpsc::channel(4);
let _token = channel
.register_connection("session-1", "conn-1", sender)
.await;
channel.unregister_connection("conn-1").await;
let error = channel
.send(OutboundMessage::assistant(
"cli",
"session-1",
None, // session_id
"hello",
None,
HashMap::new(),
))
.await
.unwrap_err();
assert!(error.to_string().contains("No active CLI connection"));
}
#[tokio::test]
async fn test_stop_cancels_token() {
let channel = CliChannel::new();
let (sender, _receiver) = mpsc::channel(4);
let token = channel
.register_connection("session-1", "conn-1", sender)
.await;
assert!(!token.is_cancelled());
channel.stop().await.unwrap();
assert!(token.is_cancelled());
}
#[tokio::test]
async fn test_register_connection_does_not_cancel_old_token() {
// 验证register_connection 替换旧连接时不会 cancel token
// 只有 stop() 才会 cancel
let channel = CliChannel::new();
let (sender1, _receiver1) = mpsc::channel(4);
let token1 = channel
.register_connection("session-1", "conn-1", sender1)
.await;
let (sender2, _receiver2) = mpsc::channel(4);
let _token2 = channel
.register_connection("session-1", "conn-2", sender2)
.await;
// 旧 token 不应被 cancel只有 stop() 才 cancel
assert!(!token1.is_cancelled());
}
}