新增: 审批超时后端兜底,覆盖重启恢复场景

前端已有审批超时定时器(设置页可选 0/5/15/30/60 分钟),负责会话
内运行的审批超时检测。但应用重启后前端定时器丢失,从数据库恢复
的待审批无人计时,会永久卡死会话。

本次补充后端兜底机制:

- 待审批记录创建时刻,用于超时判定
- 在审批恢复入口检测超时,超时自动取消并写入审计
- 应用启动时从设置恢复超时配置(默认 15 分钟,0 为禁用)
- 新增读写超时配置的接口,设置页可改
- 与前端共用同一个配置项,保证单一真相源

同时更新待办文档,把已实现项标记为完成。
This commit is contained in:
2026-06-28 18:16:34 +08:00
parent 3fac0a618d
commit 55542abf41
11 changed files with 279 additions and 57 deletions

View File

@@ -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;
}

View File

@@ -245,6 +245,9 @@ pub const CIRCUIT_BREAKER_HELP_EVENT: bool = true;
mod guard;
use guard::GeneratingGuard;
/// 审批超时取消(由 try_continue_agent_loop 入口调用)
mod approval_timeout;
// ============================================================
// L2 统一状态机(ConvState enum + 转换守卫,单一真相源)。
// 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。
@@ -830,7 +833,7 @@ pub(crate) async fn run_agentic_loop(
// 输出平台一致性。detect() 是 OnceLock 全局缓存(启动时探一次,后续零开销),与 G1 一样
// 是 loop 不变量(整个会话不重探),与目标钉扎拼接次序无强约束(放其后,语义自然)。
let env_prompt = df_execute::EnvSnapshot::detect().await.to_prompt();
let behavior_prompt = "\n## 行为准则\n- 使用工具指令(create_project/write_file/grep/read_file/run_command 等)直接完成任务,不要让用户去终端执行命令\n- 优先使用开发工具 IPC,非必要不写独立脚本(write_file + run_command = 两次审批,拖慢工作流)\n- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定";
let behavior_prompt = "\n## AI 定位\n你是 DevFlow 的 AI 助手,拥有完整的工具链。用户只负责提需求和审批,所有执行由你完成——读写文件、运行命令、创建项目、搜索代码等都是你直接调用工具完成的。**绝不输出“请在终端执行以下命令”这类指令——你自己用 run_command 工具执行即可。**\n\n## 行为准则\n- 所有操作都通过工具完成,用户不参与执行\n- 优先使用开发工具 IPC,非必要不写独立脚本\n- 脚本需要审批通过才执行,会拖慢工作流\n- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具";
system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt);
// 对话透明化 L1:拍快照供 AiCompleted 事件携带,前端直接读取 pinned_goals 无需等 loadConversations
@@ -1929,6 +1932,12 @@ pub(crate) async fn try_continue_agent_loop(
conv_id: &str,
start_iteration: usize,
) {
// 审批超时检测:超时取消在本函数入口进行(本函数是所有审批恢复路径的入口——
// ai_approve / ai_authorize_dir / ai_continue_loop / ai_chat_stop 都调它)。
// 这里检测后把超时审批转为拒绝状态,避免用户离开后 pending 永久挂起死锁会话。
// 0 表示禁用超时(不推荐,但保留用户选择权)。检测失败(如锁中毒)不阻断续跑。
approval_timeout::cleanup_expired_approvals(app, state, conv_id).await;
// BUG-260617-05: 原 5 次独立 lock().await 造成 TOCTOU 竞态——should_continue=true 判出后、
// spawn 前用户点 stop(ai_chat_stop 复位 generating=false),续跑仍按过时快照继续 spawn。
// 修复:单次 lock 取结构化快照(所有续跑判定所需字段),无锁态判定;spawn 前单次 lock 原子

View File

@@ -450,6 +450,7 @@ pub(crate) async fn process_tool_calls(
// 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
kind: ApprovalKind::Path(req),
retry_count,
created_at: Some(std::time::SystemTime::now()),
},
);
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。
@@ -631,6 +632,7 @@ pub(crate) async fn process_tool_calls(
// 阶段3a:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。
kind: ApprovalKind::Risk { diff: approval_diff.clone() },
retry_count,
created_at: Some(std::time::SystemTime::now()),
});
// 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan)
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));

View File

@@ -79,6 +79,9 @@ pub async fn restore_pending_approvals(state: &AppState) {
// 阶段4:重启恢复的审批 retry_count=0(恢复语义即"待用户首次决策",非重试)。
// 即便审计表已有 pending 记录,恢复后用户审批执行属首次正常执行,不断路。
retry_count: 0,
// 恢复审批 created_at=重启时刻(无原挂起时间记录,按当前系统时间计)。
// 超时取消计数仍从此刻起,避免恢复后立即被误判超时。
created_at: Some(std::time::SystemTime::now()),
},
);
}
@@ -133,6 +136,7 @@ mod tests_f09_batch8_restore {
kind: ApprovalKind::Risk { diff: None },
// 阶段4:恢复的审批 retry_count=0(首次用户决策,非重试)。
retry_count: 0,
created_at: None,
},
);
}

View File

@@ -855,6 +855,10 @@ pub struct PendingApproval {
pub conversation_id: Option<String>,
/// 重启恢复的积压审批:无 live loop 持有 session.messages,审批后不 save(防空 messages 污染老对话)、不续跑
pub recovered: bool,
/// 审批创建时间(用于超时取消判定)。运行期内存态,不序列化(重启恢复的积压审批按恢复时间计)。
/// `#[serde(default)]` 使老 JSON 数据反序列化时不报错(向后兼容)。
#[serde(default)]
pub created_at: Option<std::time::SystemTime>,
/// 阶段3a:审批类型(Path 路径授权挂起 / Risk 普通 RiskLevel 审批)。
/// 老构造点不传 kind 时默认 Risk(兼容:原 risk_pending 路径行为不变)。
pub kind: ApprovalKind,

View File

@@ -53,3 +53,41 @@ pub async fn settings_delete(
pub async fn get_data_dir(state: State<'_, AppState>) -> Result<String, String> {
Ok(state.data_dir.to_string_lossy().to_string())
}
/// 读取审批超时分钟数(默认 15 分钟,0=禁用超时)
///
/// 前端 Settings 页启动时拉取,展示当前配置。
#[tauri::command]
pub async fn ai_get_approval_timeout(state: State<'_, AppState>) -> Result<u64, String> {
Ok(state.approval_timeout_minutes.load(std::sync::atomic::Ordering::SeqCst))
}
/// 设置审批超时分钟数并持久化(默认 15 分钟,0=禁用超时)
///
/// 前端 Settings 页保存时调用。值范围 0-1440(0=禁用,最大 24 小时)。
/// 注意:此命令与前端 AdvancedSection.vue 的 df-approval-timeout KV 持久化路径不同,
/// 前端走 appSettings.set 走 KV,这里作为后端热改入口。两者最终读写同一个 KV。
#[tauri::command]
pub async fn ai_set_approval_timeout(
state: State<'_, AppState>,
minutes: u64,
) -> Result<u64, String> {
// 范围校验(0-1440 分钟,即 0-24 小时)
if minutes > 1440 {
return Err("审批超时分钟数超出范围(最大 1440 = 24 小时)".to_string());
}
// 持久化到 Settings KV(与前端同 key,值为毫秒)
let ms = minutes * 60_000;
state
.settings
.set(
crate::state::APPROVAL_TIMEOUT_KEY,
&ms.to_string(),
)
.await
.map_err(err_str)?;
// 更新内存
state.approval_timeout_minutes.store(minutes, std::sync::atomic::Ordering::SeqCst);
tracing::info!("[APPROVAL-TIMEOUT] 已更新: {} 分钟 ({} ms)", minutes, ms);
Ok(minutes)
}