修复: gen_stream判重+重试分类+状态机最短路径+既有测试修复

This commit is contained in:
lxy
2026-08-12 00:29:06 +08:00
parent 9407f821b9
commit d31a27512c
36 changed files with 2614 additions and 264 deletions
+23 -10
View File
@@ -163,7 +163,7 @@ pub(super) async fn insert_pending_approval(
// (High only),本 guard 按 tc_id 匹配(覆盖 Med + High 残留场景)。
// 同 tc_id 已有审计落定记录 → retry_count≥1,跳过审批 + emit Completed,断死循环。
// 兜底:flag 关或无审计记录 → retry_count=0,等价原行为。
let retry_count = detect_retry_count(audit_repo, &draft.id).await;
let retry_count = detect_retry_count(audit_repo, conv_id, &draft.name, &draft.args).await;
if retry_count >= 1 {
let skip_msg = format!(
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
@@ -219,28 +219,41 @@ pub(super) async fn insert_pending_approval(
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
}
/// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 tc_id 重试计数。
/// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 (会话, 工具, 参数) 重试计数。
///
/// 判重语义:同会话内同工具同参数的重试。区别于旧实现按裸 tool_call_id 判重——
/// 弱模型 provider(如 sensenova)的 tool_call_id 每轮从 0 重排(gen_stream_0/1/2...),
/// 跨轮不同会话的合法调用会复用同一 id,裸 id 判重会把合法新操作误判为「同 id 死循环重试」
/// 静默跳过(实证 2026-08-11:推进任务被跳过 4 次,AI 却宣称成功)。
///
/// 返回语义:
/// - 0:审计表无该 tc_id 落定记录(或仅 pending),属首次审批执行,正常挂起。
/// - ≥1:审计表已有该 tc_id 的落定记录(executed/failed/rejected/skipped_retry),即该
/// tc_id 此前已被审批执行过一次,LLM 又用同 id 重试 → 调用方据 ≥1 跳过执行 + emit Completed,
/// 断「超时/权限错→LLM 死循环重试同 id→重新挂起→用户被迫二次授权」循环。
/// - 0:审计表无该 (conv, tool, args) 落定记录(或仅 pending),属首次审批执行,正常挂起。
/// - ≥1:审计表已有该 (conv, tool, args) 的落定记录(executed/failed/rejected/skipped_retry),
/// 即该调用此前已被审批执行过一次,LLM 又重试同参数 → 调用方据 ≥1 跳过执行 + emit Completed,
/// 断「超时/权限错→LLM 死循环重试→重新挂起→用户被迫二次授权」循环。
///
/// 实现:查 `find_by_tool_call_id`,status 为 pending 视为"尚未落定"(返 0,首次挂起审批的
/// 实现:查 `find_by_conv_tool_args`,status 为 pending 视为"尚未落定"(返 0,首次挂起审批的
/// 正常态);其余落定状态返 1。retry_count 当前仅取 0/1(断路器语义:第二次即跳过),
/// 字段类型 u32 留给未来"允许多次重试"扩展(配置上限阈值)。
///
/// 兜底/回退:flag 关(文档标记)或审计查询失败 → 返 0,等价原行为(单次审批执行,无重试防护)。
pub(super) async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id: &str) -> u32 {
pub(super) async fn detect_retry_count(
audit_repo: &AiToolExecutionRepo,
conv_id: &str,
tool_name: &str,
args: &str,
) -> u32 {
// 审计查询失败不阻断主流程(DB 故障等降级为无重试防护,返回 0 走原审批流程)
let rec = match audit_repo.find_by_tool_call_id(tc_id).await {
let rec = match audit_repo.find_by_conv_tool_args(conv_id, tool_name, args).await {
Ok(opt) => match opt {
Some(r) => r,
None => return 0, // 无记录 = 首次
},
Err(e) => {
tracing::warn!("[approval-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e);
tracing::warn!(
"[approval-retry] 查审计表 (conv={}, tool={}) 失败(降级无重试防护): {}",
conv_id, tool_name, e
);
return 0;
}
};
+1 -1
View File
@@ -376,7 +376,7 @@ pub(crate) async fn process_tool_calls(
.unwrap_or_default();
let path_str = req.raw_paths.first().cloned().unwrap_or_default();
// 锁外:(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
let retry_count = detect_retry_count(&audit_repo, &draft.id).await;
let retry_count = detect_retry_count(&audit_repo, conv_id, &draft.name, &draft.args).await;
if retry_count >= 1 {
let skip_msg = format!(
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",