refactor: 迁移 parking_lot 锁并优化阻塞 IO 与内存管理

## 锁迁移:std::sync → parking_lot
消除锁中毒(poison)导致的级联崩溃风险。parking_lot 锁不会中毒,
且性能更优。迁移覆盖全部生产代码:
- experts/mod.rs: 4 RwLock + 13 expect
- skills/mod.rs: 1 RwLock + 11 expect
- tools/registry.rs: 1 RwLock + 2 expect
- gateway/model_selection.rs: 1 RwLock + 2 expect
- tools/task/repository.rs: 1 RwLock + 4 unwrap
- tools/task/runtime.rs: 2 RwLock + 17 expect
- gateway/session.rs + task/runtime.rs: stream_message_id Mutex
- gateway/processor.rs: description_generation_in_flight Mutex
- command/handler.rs + help.rs: metadata Mutex(公开 API)
- mcp/client.rs: stderr_lines Mutex

测试代码中的 std::sync::Mutex(串行化锁 + TestObserver)有意保留,
已通过 unwrap_or_else(|err| err.into_inner()) 做中毒恢复。

## P1: 阻塞 IO 迁移到 spawn_blocking
将 3 处阻塞 async worker 的操作迁移到 blocking 线程池:
- file_read.rs: read_to_string + 行处理 + base64 编码整体包入 spawn_blocking
- agent_loop.rs: 新增 preencode_images_for_request 两阶段预编码
  (顺序分配预算 → 并行 spawn_blocking 编码),build_llm_request 改为 async
- wechat.rs: media_to_send_content 改为 async,std::fs::read 用 spawn_blocking 包裹

## P2: session_history topic_histories 内存上限
新增 MAX_CACHED_TOPICS=32 上限和 evict_inactive_if_needed 方法。
超限时驱逐非活跃 topic(不在 chat_topic_ids、不在 compression_in_flight、
serial_lock 未被持有)。活跃 topic 永不误驱逐。
remove_history 同步清理 topic_serial_locks,防止无限增长。

## P3: 减少 panic 面
agent_loop.rs retry 循环的 response.expect(...) 改为 ok_or_else(...)?
返回 AgentError::Other,逻辑 bug 不再导致整个 agent 崩溃。

## 对抗性审查修复
- preencode_images_for_request: 用 seen HashSet 去重,防止同 path 重复
  编码导致 HashMap entry 覆盖(NoBudget 覆盖 Encoded 等)
- evict_inactive_if_needed: 检查 topic_serial_lock.try_lock(),防止驱逐
  正在 agent 处理中的 topic(original_topic_id 不在 chat_topic_ids 但
  agent 仍持锁)
- remove_history: 清理 topic_serial_locks

## 验证
- cargo check: 通过(仅既有 lifetime 警告)
- cargo test: 559 passed / 3 failed(均为环境/sandbox 权限问题,与本次改动无关)
This commit is contained in:
oudecheng 2026-08-06 08:18:04 +08:00
parent fa420b713f
commit bf8c227634
17 changed files with 431 additions and 273 deletions

1
Cargo.lock generated
View File

@ -1656,6 +1656,7 @@ dependencies = [
"libc",
"meval",
"mime_guess",
"parking_lot",
"prost",
"r2d2",
"r2d2_sqlite",

View File

@ -56,6 +56,7 @@ r2d2 = "0.8"
r2d2_sqlite = "0.34"
rustls = { version = "0.23", features = ["ring"] }
subtle = "2.6"
parking_lot = "0.12"
wechatbot = { path = "vendor/wechatbot" }
encoding_rs = "0.8"
libc = "0.2"

View File

@ -15,7 +15,7 @@ use crate::text::{char_count, take_prefix_chars, take_suffix_chars};
use crate::tools::{ToolContext, ToolRegistry};
use async_trait::async_trait;
use std::borrow::Cow;
use std::collections::VecDeque;
use std::collections::{HashMap, VecDeque};
use std::hash::{Hash, Hasher};
use std::io::Read;
use std::sync::Arc;
@ -43,19 +43,21 @@ const JPEG_QUALITY_STEPS: &[u8] = &[82, 72, 60, 48, 36];
const MIN_COMPRESSED_IMAGE_SIDE: u32 = 64;
const IMAGE_INPUT_NOTICE_PREFIX: &str = "[系统提示] 以下图片未能成功入模:";
/// Build content blocks from text and media paths
/// Build content blocks from text and media paths.
/// `preencoded` 包含已通过 spawn_blocking 预编码的图片结果path → PreencodeEntry
/// 预算分配已在 `preencode_images_for_request` 中完成,此处只查表组装。
fn build_content_blocks(
text: &str,
media_paths: &[String],
budget: &mut ImageInlineBudget,
preencoded: &HashMap<String, PreencodeEntry>,
) -> Vec<ContentBlock> {
build_content_blocks_with_image_budget(text, media_paths, budget)
build_content_blocks_with_image_budget(text, media_paths, preencoded)
}
fn build_content_blocks_with_image_budget(
text: &str,
media_paths: &[String],
budget: &mut ImageInlineBudget,
preencoded: &HashMap<String, PreencodeEntry>,
) -> Vec<ContentBlock> {
let mut blocks = Vec::new();
let mut skipped_image_notices = Vec::new();
@ -72,27 +74,33 @@ fn build_content_blocks_with_image_budget(
continue;
}
let Some(target_tokens) = budget.take_next_image_tokens() else {
match preencoded.get(path) {
Some(PreencodeEntry::Encoded(mime_type, base64_data)) => {
let url = format!("data:{};base64,{}", mime_type, base64_data);
blocks.push(ContentBlock::image_url(url));
}
Some(PreencodeEntry::NoBudget) => {
tracing::warn!(media_path = %path, "Skipping image media ref because no LLM context budget remains");
skipped_image_notices.push(format!(
"- {}:模型上下文预算不足,当前轮无法读取这张图片,请直接告知用户图片未成功入模。",
display_media_name(path)
));
continue;
};
match encode_image_to_base64_with_budget(path, target_tokens) {
Ok((mime_type, base64_data)) => {
let url = format!("data:{};base64,{}", mime_type, base64_data);
blocks.push(ContentBlock::image_url(url));
}
Err(err) => {
tracing::warn!(media_path = %path, target_tokens = target_tokens, error = %err, "Skipping image media ref after compression failed");
Some(PreencodeEntry::Failed) => {
tracing::warn!(media_path = %path, "Skipping image media ref after compression failed");
skipped_image_notices.push(format!(
"- {}:图片压缩或编码失败,当前轮无法读取这张图片,请直接告知用户图片未成功入模。",
display_media_name(path)
));
}
None => {
// 不在 preencoded map 中:可能是 preencode 时被 filter_images_by_age_and_count
// 过滤掉的旧图片,或 preencode 逻辑遗漏。按编码失败处理。
tracing::warn!(media_path = %path, "Image media ref not found in preencoded map");
skipped_image_notices.push(format!(
"- {}:图片压缩或编码失败,当前轮无法读取这张图片,请直接告知用户图片未成功入模。",
display_media_name(path)
));
continue;
}
}
}
@ -655,11 +663,14 @@ fn canonicalise_json(value: &serde_json::Value) -> serde_json::Value {
}
/// Convert ChatMessage to LLM Message format
fn chat_message_to_llm_message(m: &ChatMessage, image_budget: &mut ImageInlineBudget) -> Message {
fn chat_message_to_llm_message(
m: &ChatMessage,
preencoded: &HashMap<String, PreencodeEntry>,
) -> Message {
let content = if m.media_refs.is_empty() {
vec![ContentBlock::text(&m.content)]
} else {
build_content_blocks(&m.content, &m.media_refs, image_budget)
build_content_blocks(&m.content, &m.media_refs, preencoded)
};
Message {
@ -672,6 +683,97 @@ fn chat_message_to_llm_message(m: &ChatMessage, image_budget: &mut ImageInlineBu
}
}
/// 预编码请求中所有图片:顺序分配预算后并行 spawn_blocking 编码。
///
/// 两阶段设计:
/// 1. 顺序扫描所有消息的 media_refs对每张支持编码的图片调用 `budget.take_next_image_tokens()`
/// 收集 `(path, target_tokens)` 列表。预算分配必须顺序以保证确定性。
/// 预算耗尽后剩余图片不编码(返回 `PreencodeStatus::NoBudget`)。
/// 2. 对收集到的列表并行 `spawn_blocking` 执行 `encode_image_to_base64_with_budget`
/// IO + JPEG 压缩CPU 密集),避免阻塞 tokio worker。
///
/// 返回 `HashMap<path, PreencodeEntry>``build_content_blocks_with_image_budget`
/// 据此组装结果或生成跳过提示,不再调用 `take_next_image_tokens`budget 已在此消费完)。
async fn preencode_images_for_request(
messages: &[ChatMessage],
budget: &mut ImageInlineBudget,
) -> HashMap<String, PreencodeEntry> {
// 阶段 1顺序分配预算收集待编码列表 + 记录无预算的图片
let mut to_encode: Vec<(String, usize)> = Vec::new();
let mut result: HashMap<String, PreencodeEntry> = HashMap::new();
// 同一 path 跨多条消息重复出现时,只处理第一次(只扣减一次预算、只编码一次)。
// 后续出现直接查表复用,避免 HashMap entry 覆盖导致的状态丢失
// (如 NoBudget 覆盖 Encoded或第二次编码失败覆盖第一次成功
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for m in messages {
for path in &m.media_refs {
if supported_image_mime_type(path).is_none() {
continue; // 非图片跳过build_content_blocks 也会跳过)
}
if !seen.insert(path.clone()) {
continue; // 同一 path 已处理跳过build_content_blocks 会查表复用)
}
match budget.take_next_image_tokens() {
Some(target_tokens) => {
to_encode.push((path.clone(), target_tokens));
}
None => {
result.insert(path.clone(), PreencodeEntry::NoBudget);
}
}
}
}
if to_encode.is_empty() {
return result;
}
// 阶段 2并行 spawn_blocking 编码
let mut join_set: tokio::task::JoinSet<(String, PreencodeEntry)> =
tokio::task::JoinSet::new();
for (path, target_tokens) in to_encode {
join_set.spawn_blocking(move || {
match encode_image_to_base64_with_budget(&path, target_tokens) {
Ok((mime, base64)) => (path, PreencodeEntry::Encoded(mime, base64)),
Err(err) => {
tracing::warn!(
media_path = %path,
target_tokens = target_tokens,
error = %err,
"Image preencode failed"
);
(path, PreencodeEntry::Failed)
}
}
});
}
while let Some(join_result) = join_set.join_next().await {
match join_result {
Ok((path, entry)) => {
result.insert(path, entry);
}
Err(e) => {
tracing::warn!(error = %e, "Image preencode task panicked");
}
}
}
result
}
/// 单张图片的预编码结果
#[derive(Debug, Clone)]
enum PreencodeEntry {
/// 成功编码:(mime, base64)
Encoded(String, String),
/// 预算不足
NoBudget,
/// 编码失败
Failed,
}
/// AgentLoop - Stateless agent that processes messages with tool calling support.
/// History is managed externally by SessionManager.
pub struct AgentLoop {
@ -1085,7 +1187,8 @@ impl AgentLoop {
system_prompt_context,
tools.clone(),
tools_tokens,
);
)
.await;
// Set up streaming delta consumer
// Pre-generate the message ID so stream deltas and the final assistant
@ -1205,7 +1308,11 @@ impl AgentLoop {
}
}
let response = response.expect("retry loop must set response or return");
let response = response.ok_or_else(|| {
AgentError::Other(
"retry loop exited without setting response or returning".to_string(),
)
})?;
// Signal stream end if handler exists
let had_streaming = self.emitted_message_handler.is_some();
@ -1460,7 +1567,7 @@ impl AgentLoop {
/// 优化token 估算直接基于 ChatMessage 累加字段长度(轻量),
/// 不再构造中间 text_only_messages: Vec<Message>(避免 N 条消息 × 5 字段双克隆)。
/// 工具 token 估算在循环外预算后传入tools_tokens
fn build_llm_request(
async fn build_llm_request(
&self,
messages: &[ChatMessage],
system_prompt_context: Option<&SystemPromptContext>,
@ -1490,6 +1597,11 @@ impl AgentLoop {
image_token_budget_for_request(&self.runtime_config, text_tokens, tools_tokens);
let mut image_budget = ImageInlineBudget::new(image_tokens, image_count);
// 两阶段图片编码:先顺序分配预算,收集 (path, target_tokens) 列表;
// 再并行 spawn_blocking 编码,避免阻塞 async worker。
let preencoded =
preencode_images_for_request(filtered_messages_ref, &mut image_budget).await;
let mut messages_for_llm: Vec<Message> = Vec::with_capacity(filtered_messages_ref.len() + 2);
if let Some(ref prompt) = system_prompt {
messages_for_llm.push(Message::system(prompt.content.clone()));
@ -1497,7 +1609,7 @@ impl AgentLoop {
messages_for_llm.extend(
filtered_messages_ref
.iter()
.map(|message| chat_message_to_llm_message(message, &mut image_budget)),
.map(|message| chat_message_to_llm_message(message, &preencoded)),
);
ChatCompletionRequest {
@ -1609,7 +1721,7 @@ impl AgentLoop {
);
messages.push(summary_request);
let request = self.build_llm_request(messages, system_prompt_context, None, 0);
let request = self.build_llm_request(messages, system_prompt_context, None, 0).await;
let max_retries = self.runtime_config.max_retries as usize;
for attempt in 0..=max_retries {
@ -2055,8 +2167,8 @@ mod tests {
}],
);
let mut image_budget = ImageInlineBudget::new(0, 0);
let provider_message = chat_message_to_llm_message(&chat_message, &mut image_budget);
let preencoded = HashMap::new();
let provider_message = chat_message_to_llm_message(&chat_message, &preencoded);
assert_eq!(provider_message.role, "assistant");
assert_eq!(provider_message.tool_calls.as_ref().unwrap().len(), 1);
@ -2075,8 +2187,8 @@ mod tests {
let chat_message =
ChatMessage::assistant_with_reasoning("final answer", "hidden chain of thought");
let mut image_budget = ImageInlineBudget::new(0, 0);
let provider_message = chat_message_to_llm_message(&chat_message, &mut image_budget);
let preencoded = HashMap::new();
let provider_message = chat_message_to_llm_message(&chat_message, &preencoded);
assert_eq!(provider_message.role, "assistant");
assert_eq!(
@ -2122,36 +2234,33 @@ mod tests {
);
}
#[test]
fn test_build_content_blocks_skips_non_image_media_refs() {
#[tokio::test]
async fn test_build_content_blocks_skips_non_image_media_refs() {
let temp_dir = tempdir().unwrap();
let pdf_path = temp_dir.path().join("demo.pdf");
std::fs::write(&pdf_path, b"%PDF-1.4").unwrap();
let messages = vec![ChatMessage::user_with_media("hello", vec![pdf_path.to_string_lossy().to_string()])];
let mut budget = ImageInlineBudget::new(1_000, 0);
let blocks = build_content_blocks(
"hello",
&[pdf_path.to_string_lossy().to_string()],
&mut budget,
);
let preencoded = preencode_images_for_request(&messages, &mut budget).await;
let blocks = build_content_blocks("hello", &[pdf_path.to_string_lossy().to_string()], &preencoded);
assert_eq!(blocks.len(), 1);
assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "hello"));
}
#[test]
fn test_build_content_blocks_keeps_supported_images() {
#[tokio::test]
async fn test_build_content_blocks_keeps_supported_images() {
let temp_dir = tempdir().unwrap();
let jpg_path = temp_dir.path().join("demo.jpg");
let image = image::DynamicImage::new_rgb8(8, 8);
image.save(&jpg_path).unwrap();
let path_str = jpg_path.to_string_lossy().to_string();
let messages = vec![ChatMessage::user_with_media("hello", vec![path_str.clone()])];
let mut budget = ImageInlineBudget::new(10_000, 1);
let blocks = build_content_blocks(
"hello",
&[jpg_path.to_string_lossy().to_string()],
&mut budget,
);
let preencoded = preencode_images_for_request(&messages, &mut budget).await;
let blocks = build_content_blocks("hello", &[path_str], &preencoded);
assert_eq!(blocks.len(), 2);
assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "hello"));
@ -2160,19 +2269,18 @@ mod tests {
);
}
#[test]
fn test_build_content_blocks_compresses_images_to_budget() {
#[tokio::test]
async fn test_build_content_blocks_compresses_images_to_budget() {
let temp_dir = tempdir().unwrap();
let png_path = temp_dir.path().join("large.png");
let image = image::DynamicImage::new_rgb8(512, 512);
image.save(&png_path).unwrap();
let path_str = png_path.to_string_lossy().to_string();
let messages = vec![ChatMessage::user_with_media("hello", vec![path_str.clone()])];
let mut budget = ImageInlineBudget::new(512, 1);
let blocks = build_content_blocks(
"hello",
&[png_path.to_string_lossy().to_string()],
&mut budget,
);
let preencoded = preencode_images_for_request(&messages, &mut budget).await;
let blocks = build_content_blocks("hello", &[path_str], &preencoded);
assert_eq!(blocks.len(), 2);
assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "hello"));
@ -2181,19 +2289,18 @@ mod tests {
);
}
#[test]
fn test_build_content_blocks_adds_user_visible_notice_when_image_cannot_be_sent() {
#[tokio::test]
async fn test_build_content_blocks_adds_user_visible_notice_when_image_cannot_be_sent() {
let temp_dir = tempdir().unwrap();
let jpg_path = temp_dir.path().join("demo.jpg");
let image = image::DynamicImage::new_rgb8(8, 8);
image.save(&jpg_path).unwrap();
let path_str = jpg_path.to_string_lossy().to_string();
let messages = vec![ChatMessage::user_with_media("hello", vec![path_str.clone()])];
let mut budget = ImageInlineBudget::new(0, 1);
let blocks = build_content_blocks(
"hello",
&[jpg_path.to_string_lossy().to_string()],
&mut budget,
);
let preencoded = preencode_images_for_request(&messages, &mut budget).await;
let blocks = build_content_blocks("hello", &[path_str], &preencoded);
assert_eq!(blocks.len(), 2);
assert!(matches!(&blocks[0], ContentBlock::Text { text } if text == "hello"));

View File

@ -61,11 +61,18 @@ impl WechatChannel {
.any(|pattern| pattern == "*" || pattern == sender_id)
}
fn media_to_send_content(
async fn media_to_send_content(
media: &MediaItem,
caption: Option<String>,
) -> Result<SendContent, ChannelError> {
let data = std::fs::read(&media.path).map_err(|error| {
// 媒体文件读取是阻塞 IO放到 blocking 线程池避免阻塞 async worker。
let path = media.path.clone();
let data = tokio::task::spawn_blocking(move || std::fs::read(&path))
.await
.map_err(|e| {
ChannelError::SendError(format!("WeChat media read task failed: {}", e))
})?
.map_err(|error| {
ChannelError::SendError(format!(
"WeChat media read failed for '{}': {}",
media.path, error
@ -355,7 +362,7 @@ impl Channel for WechatChannel {
} else {
None
};
let content = Self::media_to_send_content(media, caption)?;
let content = Self::media_to_send_content(media, caption).await?;
self.bot
.send_media(&msg.chat_id, content)
.await
@ -403,21 +410,21 @@ mod tests {
assert!(filename.ends_with(".silk"));
}
#[test]
fn media_to_send_content_maps_image() {
#[tokio::test]
async fn media_to_send_content_maps_image() {
let file = NamedTempFile::new().unwrap();
std::fs::write(file.path(), b"demo-image").unwrap();
let image_path = file.path().with_extension("png");
std::fs::rename(file.path(), &image_path).unwrap();
let media = MediaItem::new(image_path.to_string_lossy().to_string(), "image");
let content = WechatChannel::media_to_send_content(&media, None).unwrap();
let content = WechatChannel::media_to_send_content(&media, None).await.unwrap();
assert!(matches!(content, SendContent::Image { .. }));
}
#[test]
fn media_to_send_content_maps_generic_file() {
#[tokio::test]
async fn media_to_send_content_maps_generic_file() {
let file = NamedTempFile::new().unwrap();
std::fs::write(file.path(), b"hello").unwrap();
let doc_path = file.path().with_extension("md");
@ -425,7 +432,7 @@ mod tests {
let media = MediaItem::new(doc_path.to_string_lossy().to_string(), "file");
let content =
WechatChannel::media_to_send_content(&media, Some("note".to_string())).unwrap();
WechatChannel::media_to_send_content(&media, Some("note".to_string())).await.unwrap();
match content {
SendContent::File {

View File

@ -78,7 +78,7 @@ pub trait InChatCommandHandler: Send + Sync {
/// 负责将命令分发到合适的处理器
pub struct CommandRouter {
handlers: Vec<Box<dyn CommandHandler>>,
metadata: Arc<std::sync::Mutex<Vec<CommandMetadata>>>,
metadata: Arc<parking_lot::Mutex<Vec<CommandMetadata>>>,
}
impl CommandRouter {
@ -86,7 +86,7 @@ impl CommandRouter {
pub fn new() -> Self {
Self {
handlers: Vec::new(),
metadata: Arc::new(std::sync::Mutex::new(Vec::new())),
metadata: Arc::new(parking_lot::Mutex::new(Vec::new())),
}
}
@ -96,13 +96,13 @@ impl CommandRouter {
/// * `handler` - 要注册的处理器
pub fn register(&mut self, handler: Box<dyn CommandHandler>) {
if let Some(meta) = handler.metadata() {
self.metadata.lock().unwrap().push(meta);
self.metadata.lock().push(meta);
}
self.handlers.push(handler);
}
/// 获取已注册命令的元数据列表(用于 Help 命令)
pub fn metadata_arc(&self) -> Arc<std::sync::Mutex<Vec<CommandMetadata>>> {
pub fn metadata_arc(&self) -> Arc<parking_lot::Mutex<Vec<CommandMetadata>>> {
self.metadata.clone()
}

View File

@ -3,7 +3,8 @@ use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use async_trait::async_trait;
use std::sync::{Arc, Mutex};
use parking_lot::Mutex;
use std::sync::Arc;
/// Help 命令处理器
///
@ -41,7 +42,7 @@ impl CommandHandler for HelpCommandHandler {
_cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
let metadata = self.metadata.lock().unwrap();
let metadata = self.metadata.lock();
let help_text = format_help(&metadata);
Ok(CommandResponse::success(ctx.request_id).with_message(MessageKind::Text, &help_text))

View File

@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::sync::Arc;
use parking_lot::RwLock;
#[cfg(test)]
static EXPERT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
@ -296,7 +297,6 @@ impl ExpertRuntime {
let config = self
.config
.read()
.expect("experts config rwlock poisoned")
.clone();
let catalog = ExpertCatalog::discover_with_state(
&config,
@ -305,8 +305,7 @@ impl ExpertRuntime {
);
let mut guard = self
.catalog
.write()
.expect("experts catalog rwlock poisoned");
.write();
*guard = catalog.clone();
Ok(catalog)
}
@ -315,7 +314,7 @@ impl ExpertRuntime {
/// 用于前端保存配置后即时生效,无需重启网关。
pub fn update_config(&self, new_config: ExpertsConfig) -> Result<(), String> {
{
let mut guard = self.config.write().expect("experts config rwlock poisoned");
let mut guard = self.config.write();
*guard = new_config;
}
self.reload()?;
@ -326,7 +325,6 @@ impl ExpertRuntime {
pub fn list_experts(&self) -> Vec<Expert> {
self.catalog
.read()
.expect("experts catalog rwlock poisoned")
.experts
.clone()
}
@ -336,7 +334,6 @@ impl ExpertRuntime {
let config = self
.config
.read()
.expect("experts config rwlock poisoned")
.clone();
let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd);
let disable_state = load_expert_disable_state(&self.cwd);
@ -366,7 +363,6 @@ impl ExpertRuntime {
pub fn get_expert(&self, name: &str) -> Option<Expert> {
self.catalog
.read()
.expect("experts catalog rwlock poisoned")
.find_expert(name)
.cloned()
}
@ -481,7 +477,6 @@ impl ExpertRuntime {
let config = self
.config
.read()
.expect("experts config rwlock poisoned")
.clone();
let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd);
Ok(catalog.find_expert(name).is_some())
@ -516,8 +511,7 @@ impl ExpertRuntime {
{
let mut state = self
.disable_state
.write()
.expect("experts disable_state rwlock poisoned");
.write();
match scope {
ExpertScope::User => {
if enabled {
@ -541,8 +535,7 @@ impl ExpertRuntime {
let state = self
.disable_state
.read()
.expect("experts disable_state rwlock poisoned");
.read();
let disabled_in_scopes = state.disabled_scopes_for(name);
Ok(ExpertAvailabilityChange {
@ -567,8 +560,7 @@ impl ExpertRuntime {
{
let mut sessions = self
.session_experts
.write()
.expect("experts session_experts rwlock poisoned");
.write();
sessions.insert(session_id.to_string(), expert_name.to_string());
}
persist_session_experts(&self.cwd, |state| {
@ -583,8 +575,7 @@ impl ExpertRuntime {
{
let mut sessions = self
.session_experts
.write()
.expect("experts session_experts rwlock poisoned");
.write();
sessions.remove(session_id);
}
persist_session_experts(&self.cwd, |state| {
@ -597,16 +588,14 @@ impl ExpertRuntime {
let name = {
let sessions = self
.session_experts
.read()
.expect("experts session_experts rwlock poisoned");
.read();
sessions.get(session_id).cloned()
}?;
// Filter out disabled experts.
let state = self
.disable_state
.read()
.expect("experts disable_state rwlock poisoned");
.read();
if state.is_disabled(&name) {
return None;
}

View File

@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::sync::RwLock;
use parking_lot::RwLock;
/// per-session 的用户模型覆盖选择存储。
///
@ -19,8 +19,7 @@ impl ModelSelectionStore {
pub fn set(&self, session_id: &str, provider: Option<String>, model: Option<String>) {
let mut selections = self
.selections
.write()
.expect("model selections rwlock poisoned");
.write();
if provider.is_none() && model.is_none() {
selections.remove(session_id);
} else {
@ -32,7 +31,6 @@ impl ModelSelectionStore {
pub fn get(&self, session_id: &str) -> Option<(Option<String>, Option<String>)> {
self.selections
.read()
.expect("model selections rwlock poisoned")
.get(session_id)
.cloned()
}

View File

@ -1,5 +1,6 @@
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use parking_lot::Mutex;
use tokio::sync::Semaphore;
@ -344,7 +345,7 @@ impl InboundProcessor {
// 检查并设置"生成中"守卫,防止竞态条件导致重复生成
let should_generate = {
let mut in_flight =
self.description_generation_in_flight.lock().unwrap();
self.description_generation_in_flight.lock();
if in_flight.contains(topic_id) {
false
} else {
@ -373,7 +374,7 @@ impl InboundProcessor {
Some(content) => content,
None => {
tracing::warn!(topic_id = %topic_id_clone, "No user message found for topic, skipping description generation");
in_flight.lock().unwrap().remove(&topic_id_clone);
in_flight.lock().remove(&topic_id_clone);
return;
}
};
@ -405,7 +406,7 @@ impl InboundProcessor {
}
}
// 无论成功失败,释放生成守卫
in_flight.lock().unwrap().remove(&topic_id_clone);
in_flight.lock().remove(&topic_id_clone);
});
}
}

View File

@ -60,7 +60,7 @@ pub struct BusToolCallEmitter {
chat_id: String,
metadata: HashMap<String, String>,
store: Arc<SessionStore>,
stream_message_id: std::sync::Mutex<Option<String>>,
stream_message_id: parking_lot::Mutex<Option<String>>,
}
impl BusToolCallEmitter {
@ -77,7 +77,7 @@ impl BusToolCallEmitter {
chat_id: chat_id.into(),
metadata,
store,
stream_message_id: std::sync::Mutex::new(None),
stream_message_id: parking_lot::Mutex::new(None),
}
}
}
@ -140,7 +140,7 @@ impl EmittedMessageHandler for BusToolCallEmitter {
async fn handle_stream_delta(&self, delta: &StreamDelta) {
// Get or create the stream message ID
let message_id = {
let mut guard = self.stream_message_id.lock().unwrap();
let mut guard = self.stream_message_id.lock();
guard
.get_or_insert_with(|| Uuid::new_v4().to_string())
.clone()
@ -180,7 +180,7 @@ impl EmittedMessageHandler for BusToolCallEmitter {
}
async fn set_stream_message_id(&self, id: &str) {
*self.stream_message_id.lock().unwrap() = Some(id.to_string());
*self.stream_message_id.lock() = Some(id.to_string());
}
}

View File

@ -7,6 +7,11 @@ use crate::storage::{
ConversationRepository, SessionRecord, SkillEventRepository, persistent_session_id,
};
/// 内存中缓存的 topic 历史上限。
/// 超过此值时,驱逐非活跃 topic不在 chat_topic_ids 当前引用中的 topic
/// 活跃 topic 永不被驱逐,避免影响正在进行的对话。
const MAX_CACHED_TOPICS: usize = 32;
fn preview_text(content: &str, max_chars: usize) -> String {
let mut preview = content.chars().take(max_chars).collect::<String>();
if content.chars().count() > max_chars {
@ -19,6 +24,7 @@ pub(crate) struct SessionHistory {
channel_name: String,
/// 按 topic_id 键化的内存历史缓存。
/// 不同 topic 的历史独立存储,互不干扰,支持多话题并发执行。
/// 超过 `MAX_CACHED_TOPICS` 时自动驱逐非活跃 topic。
topic_histories: HashMap<String, Vec<ChatMessage>>,
/// UI 状态:每个 chat 当前活跃的 topic按 chat_id 键)。
chat_topic_ids: HashMap<String, String>,
@ -34,6 +40,56 @@ pub(crate) struct SessionHistory {
}
impl SessionHistory {
/// 当缓存 topic 数超过 `MAX_CACHED_TOPICS` 时,驱逐非活跃 topic。
///
/// 活跃判定(任一满足即活跃,不驱逐):
/// 1. 在 `chat_topic_ids` 的 values 中UI 当前引用的 topic
/// 2. 在 `compression_in_flight` 中(正在压缩的 topic
/// 3. `topic_serial_lock` 被持有(有活跃 agent 任务正在处理该 topic
///
/// 第 3 项防止驱逐正在 agent 处理中的 topicagent 处理使用 `original_topic_id`
/// 而非 UI 状态 `chat_topic_ids`,用户切换 topic 后原 topic 不在 UI 集合中,
/// 但 agent 仍在处理(持有 serial lock此时不应驱逐。
fn evict_inactive_if_needed(&mut self) {
if self.topic_histories.len() <= MAX_CACHED_TOPICS {
return;
}
// 收集当前活跃 topic 集合
let active: HashSet<&str> = self
.chat_topic_ids
.values()
.map(|s| s.as_str())
.collect();
// 找一个非活跃 topic 驱逐
let to_evict = self.topic_histories.keys().find(|tid| {
if active.contains(tid.as_str()) || self.compression_in_flight.contains(*tid) {
return false;
}
// 检查是否有活跃 agent 任务serial lock 被持有)
// try_lock 成功 = 锁空闲 = 无活跃任务 = 可驱逐
// try_lock 失败 = 锁被持有 = 有活跃任务 = 不驱逐
if let Some(lock) = self.topic_serial_locks.get(*tid) {
if lock.try_lock().is_err() {
return false;
}
}
true
});
if let Some(tid) = to_evict.cloned() {
let msg_count = self.topic_histories.get(&tid).map(|h| h.len()).unwrap_or(0);
self.topic_histories.remove(&tid);
tracing::info!(
topic_id = %tid,
evicted_messages = msg_count,
remaining_topics = self.topic_histories.len(),
"Evicted inactive topic history to respect MAX_CACHED_TOPICS"
);
}
}
pub(crate) fn new(
channel_name: impl Into<String>,
conversations: Arc<dyn ConversationRepository>,
@ -103,6 +159,7 @@ impl SessionHistory {
}
self.topic_histories.insert(tid.to_string(), history);
self.evict_inactive_if_needed();
Ok(())
}
@ -126,6 +183,7 @@ impl SessionHistory {
pub(crate) fn set_history(&mut self, topic_id: &str, history: Vec<ChatMessage>) {
self.topic_histories.insert(topic_id.to_string(), history);
self.evict_inactive_if_needed();
}
/// 设置指定 chat 的当前 topicUI 状态)
@ -150,6 +208,10 @@ impl SessionHistory {
pub(crate) fn remove_history(&mut self, topic_id: &str) {
self.topic_histories.remove(topic_id);
self.compression_in_flight.remove(topic_id);
// 清理 serial lock防止 topic_serial_locks 无限增长
// (仅在无活跃任务时安全移除;有活跃任务时 lock 被 Arc clone 持有,
// 移除 HashMap entry 不影响正在使用 lock 的任务)
self.topic_serial_locks.remove(topic_id);
}
/// 清空指定 chat/topic 的内存历史和 DB 消息。
@ -278,6 +340,7 @@ impl SessionHistory {
.load_messages_for_topic(topic_id, Some(&sid))
.map_err(|err| AgentError::Other(format!("session history reload error: {}", err)))?;
self.topic_histories.insert(topic_id.to_string(), history);
self.evict_inactive_if_needed();
Ok(())
}

View File

@ -7,7 +7,8 @@
//! - Dynamically registers MCP tools via the Tool trait adapter
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use parking_lot::Mutex;
use tokio::sync::RwLock;
use http::{HeaderName, HeaderValue};
@ -402,7 +403,8 @@ impl McpClientManager {
);
}
// Also collect into the shared buffer (cap at 50 lines)
if let Ok(mut buf) = stderr_lines_for_task.lock() {
{
let mut buf = stderr_lines_for_task.lock();
if buf.len() < 50 {
buf.push(line);
}
@ -414,17 +416,14 @@ impl McpClientManager {
// Use default client handler (empty tuple)
let client = ().serve(transport).await.map_err(|e| {
// Include stderr summary in error if available
let stderr_summary = stderr_lines
.lock()
.ok()
.map(|buf| {
let stderr_summary = {
let buf = stderr_lines.lock();
if buf.is_empty() {
String::new()
} else {
format!("\nstderr:\n {}", buf.join("\n "))
}
})
.unwrap_or_default();
};
anyhow::anyhow!(
"Failed to establish MCP stdio connection '{}': {}{}",
effective_command.display(),

View File

@ -6,7 +6,8 @@ use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::sync::Arc;
use parking_lot::RwLock;
#[cfg(test)]
static SKILL_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
@ -136,7 +137,7 @@ impl SkillRuntime {
pub fn reload(&self) -> Result<SkillCatalog, String> {
let catalog = SkillCatalog::discover(&self.config);
let mut guard = self.catalog.write().expect("skills rwlock poisoned");
let mut guard = self.catalog.write();
*guard = catalog.clone();
Ok(catalog)
}
@ -144,18 +145,16 @@ impl SkillRuntime {
pub fn is_empty(&self) -> bool {
self.catalog
.read()
.expect("skills rwlock poisoned")
.is_empty()
}
pub fn len(&self) -> usize {
self.catalog.read().expect("skills rwlock poisoned").len()
self.catalog.read().len()
}
pub fn system_index_prompt(&self) -> Option<String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.system_index_prompt()
}
@ -167,42 +166,36 @@ impl SkillRuntime {
) -> Option<String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.system_index_prompt_filtered(allowed, denied)
}
pub fn discovery_event_payload(&self) -> serde_json::Value {
self.catalog
.read()
.expect("skills rwlock poisoned")
.discovery_event_payload()
}
pub fn offered_event_payload(&self) -> serde_json::Value {
self.catalog
.read()
.expect("skills rwlock poisoned")
.offered_event_payload()
}
pub fn activation_payload(&self, name: &str) -> Result<String, String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.activation_payload(name)
}
pub fn activation_event_payload(&self, name: &str) -> Result<serde_json::Value, String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.activation_event_payload(name)
}
pub fn list_skills(&self) -> Vec<Skill> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.skills
.clone()
}
@ -235,7 +228,6 @@ impl SkillRuntime {
pub fn get_skill(&self, name: &str) -> Option<Skill> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.find_skill(name)
.cloned()
}

View File

@ -149,6 +149,9 @@ impl Tool for FileReadTool {
});
}
// 文件读取与后续行处理/截断/base64 编码均为阻塞操作(大文件可能数 MB
// 统一放到 blocking 线程池执行,避免阻塞 tokio worker。
let result = tokio::task::spawn_blocking(move || {
// Try to read as text
match std::fs::read_to_string(&resolved) {
Ok(content) => {
@ -156,22 +159,22 @@ impl Tool for FileReadTool {
let total = all_lines.len();
if offset < 1 {
return Ok(ToolResult {
return ToolResult {
success: false,
output: String::new(),
error: Some(format!("offset must be at least 1, got {}", offset)),
});
};
}
if offset > total {
return Ok(ToolResult {
return ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"offset {} is beyond end of file ({} lines)",
offset, total
)),
});
};
}
let start = offset - 1;
@ -222,11 +225,11 @@ impl Tool for FileReadTool {
result.push_str(&format!("\n\n(End of file — {} lines total)", total));
}
Ok(ToolResult {
ToolResult {
success: true,
output: result,
error: None,
})
}
}
Err(e) => {
// Try to read as binary and encode as base64
@ -237,7 +240,7 @@ impl Tool for FileReadTool {
let mime = mime_guess::from_path(&resolved)
.first_or_octet_stream()
.to_string();
Ok(ToolResult {
ToolResult {
success: true,
output: format!(
"(Binary file: {}, {} bytes, base64 encoded)\n{}",
@ -246,16 +249,21 @@ impl Tool for FileReadTool {
encoded
),
error: None,
})
}
Err(_) => Ok(ToolResult {
}
Err(_) => ToolResult {
success: false,
output: String::new(),
error: Some(format!("Failed to read file: {}", e)),
}),
},
}
}
}
})
.await
.map_err(|e| anyhow::anyhow!("file_read blocking task failed: {}", e))?;
Ok(result)
}
}

View File

@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::sync::Arc;
use parking_lot::RwLock;
use crate::domain::tools::{Tool, ToolFunction};
@ -19,14 +20,12 @@ impl ToolRegistry {
pub fn register<T: ToolTrait + 'static>(&self, tool: T) {
self.tools
.write()
.expect("ToolRegistry lock poisoned")
.insert(tool.name().to_string(), Arc::new(tool));
}
pub fn get(&self, name: &str) -> Option<Arc<dyn ToolTrait>> {
self.tools
.read()
.expect("ToolRegistry lock poisoned")
.get(name)
.cloned()
}
@ -36,7 +35,6 @@ impl ToolRegistry {
pub fn get_all(&self) -> Vec<Arc<dyn ToolTrait>> {
self.tools
.read()
.expect("ToolRegistry lock poisoned")
.values()
.cloned()
.collect()
@ -45,7 +43,6 @@ impl ToolRegistry {
pub fn get_definitions(&self) -> Vec<Tool> {
self.tools
.read()
.expect("ToolRegistry lock poisoned")
.values()
.map(|tool| Tool {
tool_type: "function".to_string(),
@ -62,14 +59,12 @@ impl ToolRegistry {
!self
.tools
.read()
.expect("ToolRegistry lock poisoned")
.is_empty()
}
pub fn tool_names(&self) -> Vec<String> {
self.tools
.read()
.expect("ToolRegistry lock poisoned")
.keys()
.cloned()
.collect()
@ -78,7 +73,7 @@ impl ToolRegistry {
/// 创建一个排除指定工具的新 registry 副本
pub fn without(&self, exclude: &[&str]) -> Self {
let exclude_set: std::collections::HashSet<&str> = exclude.iter().copied().collect();
let tools = self.tools.read().expect("ToolRegistry lock poisoned");
let tools = self.tools.read();
let filtered: HashMap<String, Arc<dyn ToolTrait>> = tools
.iter()
.filter(|(name, _)| !exclude_set.contains(name.as_str()))
@ -87,8 +82,7 @@ impl ToolRegistry {
let new_registry = ToolRegistry::new();
*new_registry
.tools
.write()
.expect("ToolRegistry lock poisoned") = filtered;
.write() = filtered;
new_registry
}
@ -96,7 +90,7 @@ impl ToolRegistry {
/// include 中不存在于当前 registry 的名称会被静默跳过(取交集语义)。
pub fn only(&self, include: &[&str]) -> Self {
let include_set: std::collections::HashSet<&str> = include.iter().copied().collect();
let tools = self.tools.read().expect("ToolRegistry lock poisoned");
let tools = self.tools.read();
let filtered: HashMap<String, Arc<dyn ToolTrait>> = tools
.iter()
.filter(|(name, _)| include_set.contains(name.as_str()))
@ -105,8 +99,7 @@ impl ToolRegistry {
let new_registry = ToolRegistry::new();
*new_registry
.tools
.write()
.expect("ToolRegistry lock poisoned") = filtered;
.write() = filtered;
new_registry
}
}

View File

@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::sync::RwLock;
use parking_lot::RwLock;
use async_trait::async_trait;
@ -65,18 +65,17 @@ impl TaskRepository for InMemoryTaskRepository {
);
self.sessions
.write()
.unwrap()
.insert(session.id.clone(), session.clone());
tracing::debug!(
task_id = %session.id,
total_tasks = self.sessions.read().unwrap().len(),
total_tasks = self.sessions.read().len(),
"Task session saved, current repository size"
);
Ok(())
}
async fn load_task_session(&self, task_id: &str) -> Result<Option<TaskSession>, StorageError> {
let sessions = self.sessions.read().unwrap();
let sessions = self.sessions.read();
let total = sessions.len();
let keys: Vec<&str> = sessions.keys().map(|k| k.as_str()).collect();
tracing::debug!(
@ -89,7 +88,7 @@ impl TaskRepository for InMemoryTaskRepository {
}
async fn delete_task_session(&self, task_id: &str) -> Result<bool, StorageError> {
Ok(self.sessions.write().unwrap().remove(task_id).is_some())
Ok(self.sessions.write().remove(task_id).is_some())
}
async fn list_tasks_for_session(
@ -99,7 +98,6 @@ impl TaskRepository for InMemoryTaskRepository {
Ok(self
.sessions
.read()
.unwrap()
.values()
.filter(|s| s.parent_session_id == parent_session_id)
.cloned()
@ -113,7 +111,6 @@ impl TaskRepository for InMemoryTaskRepository {
Ok(self
.sessions
.read()
.unwrap()
.values()
.filter(|s| s.parent_topic_id.as_ref() == Some(&parent_topic_id.to_string()))
.cloned()
@ -123,7 +120,7 @@ impl TaskRepository for InMemoryTaskRepository {
async fn cleanup_expired_tasks(&self, ttl_hours: u64) -> Result<usize, StorageError> {
let now = current_timestamp();
let ttl_millis = ttl_hours * 3600 * 1000;
let mut sessions = self.sessions.write().unwrap();
let mut sessions = self.sessions.write();
let before = sessions.len();
sessions.retain(|_, s| now - s.updated_at < ttl_millis as i64);
Ok(before - sessions.len())

View File

@ -1,7 +1,8 @@
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use std::sync::Arc;
use parking_lot::RwLock;
use std::time::Duration;
use async_trait::async_trait;
@ -113,7 +114,7 @@ struct SubAgentEmitter {
store: Arc<SessionStore>,
/// 子/孙智能体自身的 task_id用于持久化时作为 scope_key
task_id: String,
stream_message_id: std::sync::Mutex<Option<String>>,
stream_message_id: parking_lot::Mutex<Option<String>>,
}
#[async_trait]
@ -173,7 +174,7 @@ impl EmittedMessageHandler for SubAgentEmitter {
async fn handle_stream_delta(&self, delta: &StreamDelta) {
let message_id = {
let mut guard = self.stream_message_id.lock().unwrap();
let mut guard = self.stream_message_id.lock();
guard
.get_or_insert_with(|| uuid::Uuid::new_v4().to_string())
.clone()
@ -212,7 +213,7 @@ impl EmittedMessageHandler for SubAgentEmitter {
}
async fn set_stream_message_id(&self, id: &str) {
*self.stream_message_id.lock().unwrap() = Some(id.to_string());
*self.stream_message_id.lock() = Some(id.to_string());
}
}
@ -548,7 +549,7 @@ impl DefaultSubAgentRuntime {
metadata,
store: self.store.clone(),
task_id: session.id.clone(),
stream_message_id: std::sync::Mutex::new(None),
stream_message_id: parking_lot::Mutex::new(None),
},
self.conversation_repository.clone(),
session.session_id.clone(),
@ -1298,7 +1299,7 @@ impl SubagentRuntime {
let mut guard = self
.catalog
.write()
.expect("subagent catalog rwlock poisoned");
;
*guard = new_catalog;
Ok(())
}
@ -1308,11 +1309,11 @@ impl SubagentRuntime {
let state = self
.disable_state
.read()
.expect("subagent state rwlock poisoned");
;
let catalog = self
.catalog
.read()
.expect("subagent catalog rwlock poisoned");
;
let mut items: Vec<SubagentWithStatus> = catalog
.all()
.iter()
@ -1339,11 +1340,11 @@ impl SubagentRuntime {
let state = self
.disable_state
.read()
.expect("subagent state rwlock poisoned");
;
let catalog = self
.catalog
.read()
.expect("subagent catalog rwlock poisoned");
;
catalog
.names()
.into_iter()
@ -1356,13 +1357,13 @@ impl SubagentRuntime {
let state = self
.disable_state
.read()
.expect("subagent state rwlock poisoned");
;
if state.is_disabled(name) {
return None;
}
self.catalog
.read()
.expect("subagent catalog rwlock poisoned")
.find(name)
.cloned()
}
@ -1372,11 +1373,11 @@ impl SubagentRuntime {
let state = self
.disable_state
.read()
.expect("subagent state rwlock poisoned");
;
let catalog = self
.catalog
.read()
.expect("subagent catalog rwlock poisoned");
;
let available_defs: Vec<&SubagentDef> = catalog
.all()
.into_iter()
@ -1417,11 +1418,11 @@ impl SubagentRuntime {
let state = self
.disable_state
.read()
.expect("subagent state rwlock poisoned");
;
let catalog = self
.catalog
.read()
.expect("subagent catalog rwlock poisoned");
;
let available_defs: Vec<&SubagentDef> = catalog
.all()
.into_iter()
@ -1487,7 +1488,7 @@ impl SubagentRuntime {
if self
.catalog
.read()
.expect("subagent catalog rwlock poisoned")
.find(name)
.is_none()
{
@ -1514,7 +1515,7 @@ impl SubagentRuntime {
let mut state = self
.disable_state
.write()
.expect("subagent state rwlock poisoned");
;
match scope {
SubagentScope::User => {
if enabled {
@ -1537,7 +1538,7 @@ impl SubagentRuntime {
let state = self
.disable_state
.read()
.expect("subagent state rwlock poisoned");
;
let disabled_in_scopes = state.disabled_scopes_for(name);
Ok(SubagentAvailabilityChange {
@ -1568,7 +1569,7 @@ impl SubagentRuntime {
let catalog = self
.catalog
.read()
.expect("subagent catalog rwlock poisoned");
;
catalog
.find(name)
.ok_or_else(|| format!("subagent '{}' not found", name))?
@ -1634,7 +1635,7 @@ impl SubagentRuntime {
let catalog = self
.catalog
.read()
.expect("subagent catalog rwlock poisoned");
;
if catalog.find(name).is_some() {
return Err(format!("subagent '{}' already exists", name));
}
@ -1688,7 +1689,7 @@ impl SubagentRuntime {
let catalog = self
.catalog
.read()
.expect("subagent catalog rwlock poisoned");
;
let def = catalog
.find(name)
.ok_or_else(|| format!("subagent '{}' not found", name))?;