PicoBot/src/gateway/processor.rs
oudecheng 452c5ad0ef fix(gateway): 修复长会话后新消息无响应——僵尸子代理清理、panic 兜底与运行预算
- 发送 ExecutionCompleted 前惰性清理僵尸 running 子代理(DB running 但执行任务已消失),解除 pending 阻塞
- processor panic 路径补发 error 通知 + ExecutionCompleted,防止前端永久 loading
- agent 单轮增加墙钟预算 max_run_secs,超时优雅退出释放 topic 串行锁
- topic 串行锁等待增加 info/warn 日志,长等待可观测
- sanitize 清理结果回写 DB(delete_messages_by_ids),消除每次加载的重复修复
- storage 新增 keyset 分页与按 ID 批量删除的单元测试
2026-08-18 09:33:07 +08:00

781 lines
36 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 futures_util::FutureExt;
use parking_lot::Mutex;
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::Semaphore;
use crate::agent::{AgentError, PersistingEmittedMessageHandler};
use crate::bus::{InboundMessage, MessageBus, OutboundMessage};
use crate::command::adapter::InputAdapter;
use crate::command::adapters::channel::ChannelInputAdapter;
use crate::command::handler::CommandRouter;
use crate::command::handlers::delete_topic::DeleteTopicCommandHandler;
use crate::command::handlers::get_current::GetCurrentSessionCommandHandler;
use crate::command::handlers::help::HelpCommandHandler;
use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
use crate::command::handlers::load_topic::LoadTopicCommandHandler;
use crate::command::handlers::rename_topic::RenameTopicCommandHandler;
use crate::command::handlers::save_session::SaveSessionCommandHandler;
use crate::command::handlers::save_topic::SaveTopicCommandHandler;
use crate::command::handlers::session::SessionCommandHandler;
use crate::command::handlers::stop_execution::StopExecutionCommandHandler;
use crate::command::handlers::switch_topic::SwitchTopicCommandHandler;
use crate::config::LLMProviderConfig;
use crate::gateway::agent_factory::build_system_prompt_provider;
use crate::gateway::cancel_manager::CancelManager;
use crate::providers::{ProviderRuntimeConfig, create_provider};
use crate::storage::persistent_session_id;
use crate::topic_description::generate_topic_description;
use super::message_prepare::enrich_user_content_with_media_refs;
use super::session::{BusToolCallEmitter, SessionManager};
#[derive(Clone)]
pub struct InboundProcessor {
bus: Arc<MessageBus>,
session_manager: SessionManager,
semaphore: Arc<Semaphore>,
provider_config: LLMProviderConfig,
command_router: Arc<CommandRouter>,
cancel_manager: CancelManager,
description_generation_in_flight: Arc<Mutex<HashSet<String>>>,
/// 子代理运行时(用于 ExecutionCompleted 兜底前的僵尸清理)
subagent_executor: Option<Arc<dyn crate::tools::SubAgentRuntime>>,
}
impl InboundProcessor {
pub fn new(
bus: Arc<MessageBus>,
session_manager: SessionManager,
semaphore: Arc<Semaphore>,
provider_config: LLMProviderConfig,
cancel_manager: CancelManager,
subagent_executor: Option<Arc<dyn crate::tools::SubAgentRuntime>>,
) -> Self {
// 创建命令路由器并注册处理器
let mut command_router = CommandRouter::new();
let store = session_manager.store();
// 注册 Session 处理器
let session_handler =
SessionCommandHandler::new(store.clone()).with_session_manager(session_manager.clone());
command_router.register(Box::new(session_handler));
// 注册 list_sessions 处理器
command_router.register(Box::new(ListSessionsCommandHandler::new(store.clone())));
// 注册 switch_topic 处理器
let switch_handler = SwitchTopicCommandHandler::new(store.clone())
.with_session_manager(session_manager.clone());
command_router.register(Box::new(switch_handler));
// 创建 system_prompt_provider用于 save_session, save_topic, get_current
// 与 AgentFactory::create 共享同一构建逻辑,确保保存到文件的系统提示词
// 与 LLM 实际接收的提示词完全一致(含 Expert/Subagent/Todo
let system_prompt_provider = build_system_prompt_provider(
0, // 命令侧不需要 reinject 逻辑
provider_config.clone(),
session_manager.store().clone(),
session_manager.skills(),
session_manager.experts(),
session_manager.subagent_runtime(),
);
// 注册 get_current 处理器
command_router.register(Box::new(
GetCurrentSessionCommandHandler::new(store.clone())
.with_system_prompt_provider(system_prompt_provider.clone()),
));
// 注册 load_topic 处理器
command_router.register(Box::new(LoadTopicCommandHandler::new(store.clone())));
// 注册 save_session 处理器
command_router.register(Box::new(SaveSessionCommandHandler::new(
store.clone(),
session_manager.task_repository(),
system_prompt_provider.clone(),
)));
// 注册 save_topic 处理器
command_router.register(Box::new(SaveTopicCommandHandler::new(
store.clone(),
session_manager.task_repository(),
system_prompt_provider,
)));
// 注册 delete_topic 处理器
command_router.register(Box::new(
DeleteTopicCommandHandler::new(store.clone())
.with_session_manager(session_manager.clone()),
));
// 注册 rename_topic 处理器
command_router.register(Box::new(RenameTopicCommandHandler::new(store.clone())));
// 注册 help 处理器(最后注册,获取所有已注册命令的元数据)
let metadata = command_router.metadata_arc();
command_router.register(Box::new(HelpCommandHandler::new(metadata)));
// 注册 stop_execution 处理器
command_router.register(Box::new(StopExecutionCommandHandler::new(
cancel_manager.clone(),
session_manager.clone(),
subagent_executor.clone(),
)));
Self {
bus,
session_manager,
semaphore,
provider_config,
command_router: Arc::new(command_router),
cancel_manager,
description_generation_in_flight: Arc::new(Mutex::new(HashSet::new())),
subagent_executor,
}
}
pub async fn run(self) {
let max_concurrent = self.semaphore.available_permits();
tracing::info!(
max_concurrent_requests = max_concurrent,
"Inbound processor started"
);
loop {
// 1. 消费消息 (channel 关闭时返回 None优雅退出)
let inbound = match self.bus.consume_inbound().await {
Some(msg) => msg,
None => {
tracing::info!("Inbound bus closed, stopping inbound processor");
break;
}
};
tracing::debug!(
channel = %inbound.channel,
chat_id = %inbound.chat_id,
trace_id = %inbound.trace_id,
sender = %inbound.sender_id,
content_len = %inbound.content.len(),
media_count = %inbound.media.len(),
"Processing inbound message"
);
// 2. 获取 semaphore permit控制并发
let permit = match self.semaphore.clone().acquire_owned().await {
Ok(permit) => permit,
Err(_) => {
tracing::error!("Semaphore closed, stopping inbound processor");
break;
}
};
// 3. 克隆 processor 用于新任务
let processor = self.clone();
// 4. 独立任务处理(包含 permit任务完成自动释放
// spawn 不自动传播父 span用 traced() 重建 span 上下文,
// 使 process_one 内所有日志携带 trace_id/chat_id/session_id。
let trace_id = inbound.trace_id.clone();
let chat_id_for_span = inbound.chat_id.clone();
let session_id_for_span =
crate::storage::persistent_session_id(&inbound.channel, &inbound.chat_id);
// panic 兜底需用panic 时 inbound 已被 process_one 消费,提前克隆路由字段
let panic_channel = inbound.channel.clone();
let panic_chat_id = inbound.chat_id.clone();
let panic_trace_id = inbound.trace_id.clone();
let panic_session_id = session_id_for_span.clone();
let panic_forwarded_metadata = inbound.forwarded_metadata.clone();
tokio::spawn(crate::observability::tracing_ctx::traced(
&trace_id,
&chat_id_for_span,
&session_id_for_span,
async move {
let _permit = permit; // 持有 permit 直到任务完成
// catch_unwind 将 panic 归一化为错误:否则工具/历史清理中的
// panic 只会终止任务并打 panic hook 日志,跳过错误日志与指标,
// 用户消息被静默吞掉。参考 channels/wechat.rs 的同类用法。
let result = std::panic::AssertUnwindSafe(processor.process_one(inbound))
.catch_unwind()
.await;
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => {
tracing::error!(
error = %crate::utils::format_error_chain(&e),
"Message processing failed"
);
crate::observability::metrics::record_message_processing_error();
}
Err(payload) => {
let panic_msg = crate::utils::panic_payload_message(&payload);
tracing::error!(
error = %panic_msg,
"Message processing panicked"
);
crate::observability::metrics::record_message_processing_error();
// panic 兜底process_one 中途夭折,既不会发错误提示、
// 也不会发 ExecutionCompleted前端将永久停在 loading
// 且输入框被禁用(用户视角"卡死")。此处补发两者,
// 并尽力清理该 topic 的取消信号注册。
let current_topic = processor
.session_manager
.get_current_topic(&panic_channel, &panic_chat_id)
.await
.ok()
.flatten();
if let Some(ref topic_id) = current_topic {
processor.cancel_manager.remove_by_topic(topic_id).await;
}
let mut error_metadata = panic_forwarded_metadata.clone();
error_metadata
.insert("error_kind".to_string(), "panic".to_string());
if let Err(publish_error) = processor
.bus
.publish_outbound(
OutboundMessage::error_notification(
panic_channel.clone(),
panic_chat_id.clone(),
None,
format!("内部处理错误panic{panic_msg}"),
None,
error_metadata,
)
.with_trace_id(&panic_trace_id),
)
.await
{
match publish_error {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %publish_error, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %publish_error, "Failed to publish panic error outbound");
}
}
}
let mut completion_metadata = panic_forwarded_metadata;
if let Some(ref topic_id) = current_topic {
completion_metadata
.insert("topic_id".to_string(), topic_id.clone());
}
if let Err(publish_error) = processor
.bus
.publish_outbound(
OutboundMessage::execution_completed(
panic_channel,
panic_chat_id,
Some(panic_session_id),
completion_metadata,
)
.with_trace_id(&panic_trace_id),
)
.await
{
match publish_error {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %publish_error, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %publish_error, "Failed to publish panic execution_completed");
}
}
}
}
}
},
));
}
}
#[tracing::instrument(skip(self, inbound), fields(trace_id = %inbound.trace_id, chat_id = %inbound.chat_id, session_id))]
async fn process_one(&self, inbound: InboundMessage) -> Result<(), AgentError> {
// 计算正确的 session_id根据 channel_name 和 chat_id
let session_id = persistent_session_id(&inbound.channel, &inbound.chat_id);
tracing::Span::current().record("session_id", tracing::field::display(&session_id));
// 获取当前话题(封装了 session 创建逻辑)
let current_topic = self
.session_manager
.get_current_topic(&inbound.channel, &inbound.chat_id)
.await?;
// 使用 ChannelInputAdapter 尝试解析命令
let adapter = ChannelInputAdapter::new();
let ctx = crate::command::context::AdapterContext::new(&inbound.channel)
.with_session_id(&session_id);
if let Ok(Some(cmd)) = adapter.try_parse(&inbound.content, ctx) {
// 使用命令路由器处理
let mut cmd_ctx =
crate::command::context::CommandContext::new(&inbound.channel, &inbound.channel)
.with_session_id(&session_id)
.with_chat_id(&inbound.chat_id);
// 只在有话题时才设置 topic_id
if let Some(ref topic_id) = current_topic {
cmd_ctx = cmd_ctx.with_topic_id(topic_id.as_str());
}
let response = self
.command_router
.dispatch_with_response(cmd, cmd_ctx)
.await;
// 发送响应给用户
if response.success {
// 提取响应消息
// chat_id 保持为 inbound.chat_id飞书 open_id
// session_id 放入 metadata 用于会话管理
for msg in &response.messages {
if let Err(error) = self
.bus
.publish_outbound(
OutboundMessage::assistant(
inbound.channel.clone(),
inbound.chat_id.clone(),
response.metadata.get("session_id").cloned(),
msg.content.clone(),
None,
inbound.forwarded_metadata.clone(),
)
.with_trace_id(&inbound.trace_id),
)
.await
{
match error {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %error, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %error, "Failed to publish command response");
}
}
}
}
} else if let Some(error) = response.error
&& let Err(e) = self
.bus
.publish_outbound(
OutboundMessage::assistant(
inbound.channel.clone(),
inbound.chat_id.clone(),
response.metadata.get("session_id").cloned(),
format!("Error [{}]: {}", error.code, error.message),
None,
inbound.forwarded_metadata.clone(),
)
.with_trace_id(&inbound.trace_id),
)
.await
{
match e {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %e, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %e, "Failed to publish error response");
}
}
}
return Ok(());
}
// 普通消息进入 AgentLoop
// 构建 emitter metadata包含 forwarded_metadata 和 topic_id用于前端消息隔离
let mut emitter_metadata = inbound.forwarded_metadata.clone();
if let Some(ref topic_id) = current_topic {
emitter_metadata.insert("topic_id".to_string(), topic_id.clone());
}
// ── 异步子代理等待注入路径 ──
// 当主 agent 正在 wait_for_subagents 中等待(已释放 serial_lock、is_waiting=true
// 新用户消息不应启动新的 agent loop而应注入 history 并唤醒等待中的 agent。
//
// 流程:
// 1. 获取 serial_lock若 agent 正常运行则阻塞;若 agent 在 wait 中则立即获取)
// 2. 检查 is_waitingtrue → 注入 + wakeup + returnfalse → 释放锁走正常路径
//
// 安全性is_waiting 在持锁状态下检查wait_coordinator 清除 is_waiting 需先重获取锁,
// 两者互斥,无 TOCTOU。
if let Some(ref topic_id) = current_topic
&& let Some(session) = self.session_manager.get(&inbound.channel).await
{
let lock_key = topic_id.clone();
// 获取 serial_lock Arc短暂持有 session 锁)
let serial_lock = {
let mut g = session.lock().await;
g.ensure_sub_done_channel(&lock_key);
g.topic_serial_lock(&lock_key)
};
// 阻塞获取 serial_lock
// - agent 正常运行:阻塞至其完成(天然串行化)
// - agent 在 wait 中wait 已释放锁,可立即获取
// 每 30s 打 warn 标记等待进展(与 execution.rs 主路径一致的可观测性)
let _inject_guard = {
let lock_for_wait = serial_lock.clone();
let mut waited = false;
loop {
match tokio::time::timeout(
std::time::Duration::from_secs(30),
lock_for_wait.clone().lock_owned(),
)
.await
{
Ok(guard) => {
if waited {
tracing::info!(
topic_id = %lock_key,
"Injection path serial lock acquired after waiting"
);
}
break guard;
}
Err(_) => {
if !waited {
tracing::info!(
topic_id = %lock_key,
"Injection path waiting for topic serial lock"
);
waited = true;
} else {
tracing::warn!(
topic_id = %lock_key,
"Injection path still waiting for topic serial lock"
);
}
}
}
}
};
// 检查 is_waiting持锁状态下安全
let is_waiting = {
let g = session.lock().await;
g.is_waiting(&lock_key)
};
if is_waiting {
// Agent 正在 wait_for_subagents 中等待 → 注入用户消息 + 唤醒
tracing::info!(
topic_id = %lock_key,
"Topic is in waiting state, injecting user message and waking up agent"
);
let wakeup = {
let mut g = session.lock().await;
// 确保 session 和 chat 已加载
g.ensure_persistent_session(&inbound.chat_id)?;
g.ensure_chat_loaded(&inbound.chat_id, Some(&lock_key))?;
// 构造用户消息(与 prepare_and_execute_message 一致的处理流程)
let media_refs: Vec<String> =
inbound.media.iter().map(|m| m.path.clone()).collect();
let enriched_content =
enrich_user_content_with_media_refs(&inbound.content, &media_refs)?;
let user_message = g.create_user_message(&enriched_content, media_refs);
g.append_persisted_message(&inbound.chat_id, Some(&lock_key), user_message)?;
// 获取 wakeup 信号
g.wait_wakeup(&lock_key)
};
// 唤醒等待中的 agentwait_coordinator 的 select! 会捕获此通知)
wakeup.notify_one();
// _inject_guard 在此处 drop → 释放 serial_lock
// wait_coordinator 重获取锁后继续处理history 已包含新用户消息)
//
// 跳过 handle_message / cancel 注册 / execution_completed
// 因为等待中的 agent 会处理这条消息。
return Ok(());
}
// is_waiting=false_inject_guard drop 释放锁,走正常 handle_message 路径
}
let live_emitter = Arc::new(PersistingEmittedMessageHandler::new(
BusToolCallEmitter::new(
self.bus.clone(),
inbound.channel.clone(),
inbound.chat_id.clone(),
emitter_metadata,
self.session_manager.store(),
inbound.trace_id.clone(),
),
self.session_manager.store(),
&session_id,
current_topic.clone(),
));
// 保存 channel 和 chat_id 用于后续清理(因 match 中可能 move inbound
let channel = inbound.channel.clone();
let chat_id = inbound.chat_id.clone();
// 按 topic_id 注册取消信号Agent 构建时通过 Session 消费该 receiver
if let Some(ref topic_id) = current_topic {
let cancel_rx = self.cancel_manager.register(topic_id).await;
self.session_manager
.set_agent_cancel_token(&channel, &chat_id, Some(topic_id.as_str()), cancel_rx)
.await;
}
match self
.session_manager
.handle_message(
&inbound.channel,
&inbound.sender_id,
&inbound.chat_id,
&inbound.content,
inbound.media,
Some(live_emitter),
current_topic.as_deref(),
&inbound.trace_id,
)
.await
{
Ok(outbound_messages) => {
for mut outbound in outbound_messages {
outbound.metadata.extend(inbound.forwarded_metadata.clone());
// 注入 topic_id 到 outbound metadata用于前端按话题隔离消息
if let Some(ref topic_id) = current_topic {
outbound
.metadata
.insert("topic_id".to_string(), topic_id.clone());
}
// 透传 trace_id 到出站消息,保持端到端追踪贯通
outbound.trace_id = inbound.trace_id.clone();
if let Err(error) = self.bus.publish_outbound(outbound).await {
match error {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %error, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %error, "Failed to publish outbound");
}
}
}
}
// 异步生成 topic 描述(仅当描述为空且没有正在进行的生成任务时触发)
if let Some(ref topic_id) = current_topic {
let store = self.session_manager.store();
// SQLite 是同步 I/O放到 blocking 线程池,避免阻塞 tokio worker
let store_for_lookup = store.clone();
let topic_id_for_lookup = topic_id.clone();
let topic_row = tokio::task::spawn_blocking(move || {
store_for_lookup.get_topic(&topic_id_for_lookup)
})
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or(None);
if let Some(topic) = topic_row
&& (topic.description.is_none()
|| topic
.description
.as_ref()
.map(|d| d.is_empty())
.unwrap_or(true))
{
// 检查并设置"生成中"守卫,防止竞态条件导致重复生成
let should_generate = {
let mut in_flight = self.description_generation_in_flight.lock();
if in_flight.contains(topic_id) {
false
} else {
in_flight.insert(topic_id.clone());
true
}
};
if should_generate {
let provider_config = self.provider_config.clone();
let topic_id_clone = topic_id.clone();
let store_clone = store.clone();
let in_flight = self.description_generation_in_flight.clone();
tokio::spawn(async move {
// 定向查询该 topic 的第一条用户消息DB 侧 LIMIT 1
// 不再全量加载整个话题历史),并放到 blocking 线程池执行
let store_for_query = store_clone.clone();
let topic_id_for_query = topic_id_clone.clone();
let first_user_message = tokio::task::spawn_blocking(move || {
store_for_query.first_user_message_content(&topic_id_for_query)
})
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or(None);
let message_content = match first_user_message {
Some(content) => content,
None => {
tracing::warn!(topic_id = %topic_id_clone, "No user message found for topic, skipping description generation");
in_flight.lock().remove(&topic_id_clone);
return;
}
};
let runtime_config: ProviderRuntimeConfig = provider_config.into();
if let Ok(provider) = create_provider(runtime_config) {
match generate_topic_description(
provider.as_ref(),
&message_content,
)
.await
{
Ok(description) => {
let store_for_update = store_clone.clone();
let topic_id_for_update = topic_id_clone.clone();
let description_for_update = description.clone();
let update_result =
tokio::task::spawn_blocking(move || {
store_for_update.update_topic_description(
&topic_id_for_update,
&description_for_update,
)
})
.await;
match update_result {
Ok(Ok(())) => {
tracing::info!(topic_id = %topic_id_clone, description = %description, "Topic description generated");
}
Ok(Err(e)) => {
tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to update topic description");
}
Err(e) => {
tracing::error!(error = %e, topic_id = %topic_id_clone, "Topic description update task panicked");
}
}
}
Err(e) => {
tracing::error!(error = %e, topic_id = %topic_id_clone, "Failed to generate topic description");
}
}
}
// 无论成功失败,释放生成守卫
in_flight.lock().remove(&topic_id_clone);
});
}
}
}
}
Err(error) => {
tracing::error!(
error = %crate::utils::format_error_chain(&error),
"Failed to handle message"
);
crate::observability::metrics::record_message_processing_error();
let mut metadata = inbound.forwarded_metadata.clone();
metadata.insert("error_kind".to_string(), "agent_execution".to_string());
if let Err(publish_error) = self
.bus
.publish_outbound(
OutboundMessage::error_notification(
inbound.channel,
inbound.chat_id,
None, // session_id
error.to_string(),
None,
metadata,
)
.with_trace_id(&inbound.trace_id),
)
.await
{
match publish_error {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %publish_error, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %publish_error, "Failed to publish execution error outbound");
}
}
}
}
}
// 清理取消信号注册(幂等:如果已被 cancel_by_topic() 移除则为 no-op
if let Some(ref topic_id) = current_topic {
self.cancel_manager.remove_by_topic(topic_id).await;
}
// 发送执行完成信号,通知前端可以停止 loading 状态。
//
// 退出兜底safety net如果当前 topic 仍有 running 状态的子代理,
// 不发送 ExecutionCompleted。这防止 LLM 未调用 wait_for_subagents 就退出时,
// 前端过早停止 loading 导致子代理结果"丢失"的观感。
// 恢复路径:下一条用户消息触发新的 process_one → 加载 history →
// LLM 看到 "running" 占位 → 调用 wait_for_subagents → 消费 sub_done_q 结果。
//
// 僵尸清理:先剔除"DB=running 但执行任务已消失"的僵尸记录,
// 否则它们会让 pending 永远非空 → ExecutionCompleted 永远被跳过
// → 前端 loading 永不停止(用户视角"卡死")。
if let (Some(ref topic_id), Some(ref runtime)) = (current_topic.as_ref(), self.subagent_executor.as_ref()) {
runtime.reap_orphan_subagents(topic_id).await;
}
let has_pending_subagents = if let Some(ref topic_id) = current_topic {
// SQLite 是同步 I/O放到 blocking 线程池,避免阻塞 tokio worker
let store = self.session_manager.store();
let topic_id_for_query = topic_id.clone();
let pending = tokio::task::spawn_blocking(move || {
store.list_pending_subagents(&topic_id_for_query, Some("running"))
})
.await
.ok()
.and_then(|r| r.ok())
.unwrap_or_default();
if !pending.is_empty() {
tracing::debug!(
topic_id = %topic_id,
pending_count = pending.len(),
"Skipping ExecutionCompleted: pending subagents still running"
);
true
} else {
false
}
} else {
false
};
if !has_pending_subagents {
let mut completion_metadata = inbound.forwarded_metadata.clone();
if let Some(ref topic_id) = current_topic {
completion_metadata.insert("topic_id".to_string(), topic_id.clone());
}
if let Err(error) = self
.bus
.publish_outbound(
OutboundMessage::execution_completed(
channel,
chat_id,
Some(session_id),
completion_metadata,
)
.with_trace_id(&inbound.trace_id),
)
.await
{
match error {
crate::bus::BusError::Dropped => {
tracing::warn!(error = %error, "Outbound dropped (bus full)");
}
crate::bus::BusError::Closed => {
tracing::error!(error = %error, "Failed to publish execution_completed");
}
}
}
}
Ok(())
}
}