新增: 审批超时后端兜底,覆盖重启恢复场景
前端已有审批超时定时器(设置页可选 0/5/15/30/60 分钟),负责会话 内运行的审批超时检测。但应用重启后前端定时器丢失,从数据库恢复 的待审批无人计时,会永久卡死会话。 本次补充后端兜底机制: - 待审批记录创建时刻,用于超时判定 - 在审批恢复入口检测超时,超时自动取消并写入审计 - 应用启动时从设置恢复超时配置(默认 15 分钟,0 为禁用) - 新增读写超时配置的接口,设置页可改 - 与前端共用同一个配置项,保证单一真相源 同时更新待办文档,把已实现项标记为完成。
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
//! 审批超时取消
|
||||
//!
|
||||
//! 当用户长时间未处理待审批时,自动将其转为拒绝状态,防止会话永久卡死。
|
||||
//!
|
||||
//! 触发点:[`cleanup_expired_approvals`] 由 [`try_continue_agent_loop`] 入口调用,
|
||||
//! 覆盖所有审批恢复路径(ai_approve / ai_authorize_dir / ai_continue_loop 等)。
|
||||
//!
|
||||
//! 超时时长来自 AppState.approval_timeout_minutes(Settings KV 持久化,默认 15 分钟),
|
||||
//! 0 表示禁用超时。
|
||||
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::super::{
|
||||
audit::audit_finalize, conversation::save_conversation, AiChatEvent,
|
||||
};
|
||||
|
||||
/// 清理指定会话中超时的待审批。
|
||||
///
|
||||
/// 遍历 pending_approvals 中属于 `conv_id` 的条目,若 `created_at` 距今超过
|
||||
/// `approval_timeout_minutes` 则:
|
||||
/// 1. 从 pending_approvals 移除
|
||||
/// 2. 把占位 tool_result 内容替换为"审批超时已自动取消"
|
||||
/// 3. emit AiApprovalResult(rejected=超时)
|
||||
/// 4. audit_finalize 写入审计表(status=rejected)
|
||||
///
|
||||
/// 设 0 时整体跳过(禁用超时)。无 pending 或均未超时则空操作。
|
||||
/// 失败(DB 错误等)不抛错,仅日志告警,避免影响主流程。
|
||||
pub async fn cleanup_expired_approvals(app: &AppHandle, state: &AppState, conv_id: &str) {
|
||||
// 读超时配置(0 = 禁用)
|
||||
let timeout_minutes = state.approval_timeout_minutes.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if timeout_minutes == 0 {
|
||||
return;
|
||||
}
|
||||
let timeout_dur = Duration::from_secs(timeout_minutes * 60);
|
||||
let now = SystemTime::now();
|
||||
|
||||
// 单次锁取出所有超时的审批(锁内不做 await,避免锁跨 await)
|
||||
let expired: Vec<crate::commands::ai::PendingApproval> = {
|
||||
let session = state.ai_session.lock().await;
|
||||
session
|
||||
.pending_approvals
|
||||
.iter()
|
||||
.filter(|(_, a)| {
|
||||
// 只处理本会话的审批(按 conversation_id 过滤)
|
||||
a.conversation_id.as_deref() == Some(conv_id)
|
||||
&& a.created_at
|
||||
.map(|t| now.duration_since(t).map(|d| d >= timeout_dur).unwrap_or(false))
|
||||
.unwrap_or(false) // created_at=None(异常)不超时
|
||||
})
|
||||
.map(|(_, a)| a.clone())
|
||||
.collect()
|
||||
};
|
||||
|
||||
if expired.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 逐个处理超时审批(每个独立处理,一个失败不影响其他)
|
||||
for approval in expired {
|
||||
let tc_id = approval.tool_call_id.clone();
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
tool_call_id = %tc_id,
|
||||
tool_name = %approval.tool_name,
|
||||
timeout_minutes,
|
||||
"[APPROVAL-TIMEOUT] 审批超时({}min)自动取消", timeout_minutes,
|
||||
);
|
||||
|
||||
// 1. 从内存 pending_approvals 移除
|
||||
{
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.pending_approvals.remove(&tc_id);
|
||||
// 2. 替换占位 tool_result 为超时取消提示(让 LLM 看到回执不再盲猜)
|
||||
session
|
||||
.conv(conv_id)
|
||||
.messages
|
||||
.replace_tool_result_content(&tc_id, &format!("审批超时({}分钟)已自动取消,请改换其他方案", timeout_minutes));
|
||||
}
|
||||
|
||||
// 3. emit AiApprovalResult(让前端清除 pending UI)
|
||||
let ev = AiChatEvent::AiApprovalResult {
|
||||
id: tc_id.clone(),
|
||||
approved: false,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
let _ = state.ai_event_bus.publish_event(ev);
|
||||
|
||||
// 4. audit_finalize 记入审计表
|
||||
audit_finalize(
|
||||
state,
|
||||
&tc_id,
|
||||
"rejected",
|
||||
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 落库会话状态变更(占位消息替换后需持久化,防重启后丢失)
|
||||
save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await;
|
||||
}
|
||||
Reference in New Issue
Block a user