修复: 审批挂起关不了(None分支补emit+恢复对账) + obscura检测区分未装/失败(防误弹)
This commit is contained in:
@@ -521,19 +521,36 @@ pub async fn ai_approve(
|
|||||||
// F-260616-06: 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错)
|
// F-260616-06: 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错)
|
||||||
// BUG-260618-11: 区分 Err(DB 故障)与 Ok(None)(真无记录),原 unwrap_or_default 把
|
// BUG-260618-11: 区分 Err(DB 故障)与 Ok(None)(真无记录),原 unwrap_or_default 把
|
||||||
// Err 压成 None,DB 故障被「未找到」误导(实为可重试故障),对齐 audit.rs audit_finalize 三路分流。
|
// Err 压成 None,DB 故障被「未找到」误导(实为可重试故障),对齐 audit.rs audit_finalize 三路分流。
|
||||||
match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await {
|
let audit_status = match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await {
|
||||||
Ok(Some(rec)) => {
|
Ok(Some(rec)) => {
|
||||||
if rec.status == "completed" || rec.status == "rejected" || rec.status == "failed" {
|
if rec.status == "completed" || rec.status == "rejected" || rec.status == "failed" {
|
||||||
|
// BUG-260618-11 + 关闭/拒绝无效根因修复(2026-08):
|
||||||
|
// 内存无此 pending(remove 返 None)且审计表已落定(completed/rejected/failed)时,
|
||||||
|
// 说明该审批**已处理但前端状态未收敛**——典型路径:对话 pending_approvals JSON 列
|
||||||
|
// 残留陈旧条目(recovered 后内存与 DB 不同步 / 跨会话恢复竞态),前端 ApprovalPopup
|
||||||
|
// /ToolCard 显示挂起卡,用户点拒绝 → 本分支原 return Ok 不 emit 任何事件 →
|
||||||
|
// 主窗口 pendingApprovals 永不清(useAiEvents 仅 AiApprovalResult 事件驱动清)→
|
||||||
|
// 卡死"关不了"。修复:即便已处理也 emit AiApprovalResult{approved:false},
|
||||||
|
// 前端 useAiEvents:556 filter p.id !== event.id 收敛清掉(幂等,重复 emit 无害)。
|
||||||
|
// conv_id 取审计记录的 conversation_id(无则 None,前端按 id 过滤不依赖 conv_id)。
|
||||||
|
let ev = AiChatEvent::AiApprovalResult {
|
||||||
|
id: tool_call_id.clone(),
|
||||||
|
approved: false,
|
||||||
|
conversation_id: rec.conversation_id.clone(),
|
||||||
|
};
|
||||||
|
let _ = app.emit("ai-chat-event", ev.clone());
|
||||||
|
let _ = state.ai_event_bus.publish_event(ev);
|
||||||
return Ok(format!("已处理({})", rec.status));
|
return Ok(format!("已处理({})", rec.status));
|
||||||
}
|
}
|
||||||
|
rec.status // 其他状态(如 pending/interrupted/skipped_retry)继续走未找到报错
|
||||||
}
|
}
|
||||||
Ok(None) => { /* 真无记录,落到下方 return Err 未找到挂起审批 */ }
|
Ok(None) => { String::new() /* 真无记录,落到下方 return Err 未找到挂起审批 */ }
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("ai_approve: 查询 tool_call_id={} 审计记录失败(DB 故障): {}", tool_call_id, e);
|
tracing::error!("ai_approve: 查询 tool_call_id={} 审计记录失败(DB 故障): {}", tool_call_id, e);
|
||||||
return Err(format!("查询审批记录失败(DB 故障),请重试: {}", e));
|
return Err(format!("查询审批记录失败(DB 故障),请重试: {}", e));
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
return Err(format!("未找到挂起的审批: {}", tool_call_id));
|
return Err(format!("未找到挂起的审批: {} ({})", tool_call_id, audit_status));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// 阶段3a 决策层分离:ai_approve 只消费 kind==Risk(普通 Med/High 审批,一次性 approve/reject)。
|
// 阶段3a 决策层分离:ai_approve 只消费 kind==Risk(普通 Med/High 审批,一次性 approve/reject)。
|
||||||
@@ -879,7 +896,29 @@ pub async fn ai_authorize_dir(
|
|||||||
let mut session = state.ai_session.lock().await;
|
let mut session = state.ai_session.lock().await;
|
||||||
let v = match session.pending_approvals.remove(&tool_call_id) {
|
let v = match session.pending_approvals.remove(&tool_call_id) {
|
||||||
Some(a) => a,
|
Some(a) => a,
|
||||||
None => return Err(format!("未找到路径授权挂起: {}", tool_call_id)),
|
None => {
|
||||||
|
// 内存无此挂起。释放锁后查审计表(避免持锁 await DB)。
|
||||||
|
drop(session);
|
||||||
|
// 关闭/拒绝无效根因修复(2026-08,对齐 ai_approve None 分支同款修复):
|
||||||
|
// path 类审批同样存在"已处理但前端未收敛"场景(对话 pending_approvals JSON 列残留
|
||||||
|
// 陈旧 path 条目 / 跨会话恢复竞态)。原此处直接 return Err 不 emit → 前端
|
||||||
|
// pendingApprovals(kind='path')永不清 → 卡死"关不了"。修复:审计已落定时
|
||||||
|
// emit AiApprovalResult{approved:false} 让前端收敛(幂等)。
|
||||||
|
match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await {
|
||||||
|
Ok(Some(rec)) if rec.status == "completed" || rec.status == "rejected" || rec.status == "failed" => {
|
||||||
|
let ev = AiChatEvent::AiApprovalResult {
|
||||||
|
id: tool_call_id.clone(),
|
||||||
|
approved: false,
|
||||||
|
conversation_id: rec.conversation_id.clone(),
|
||||||
|
};
|
||||||
|
let _ = app.emit("ai-chat-event", ev.clone());
|
||||||
|
let _ = state.ai_event_bus.publish_event(ev);
|
||||||
|
return Ok(format!("已处理({})", rec.status));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return Err(format!("未找到路径授权挂起: {}", tool_call_id));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let __hold864 = __lock_t864.elapsed();
|
let __hold864 = __lock_t864.elapsed();
|
||||||
if __hold864 > std::time::Duration::from_millis(30) {
|
if __hold864 > std::time::Duration::from_millis(30) {
|
||||||
|
|||||||
@@ -379,29 +379,122 @@ pub async fn ai_conversation_switch(
|
|||||||
|
|
||||||
// 从 DB 恢复本对话的挂起审批快照(重启/切回时,之前 save_conversation 持久化的 pending_approvals)
|
// 从 DB 恢复本对话的挂起审批快照(重启/切回时,之前 save_conversation 持久化的 pending_approvals)
|
||||||
// 反序列化后插入 session.pending_approvals,使 ai_pending_tool_calls 可查回、审批卡片恢复。
|
// 反序列化后插入 session.pending_approvals,使 ai_pending_tool_calls 可查回、审批卡片恢复。
|
||||||
if let Some(pending_json) = &record.pending_approvals {
|
//
|
||||||
if pending_json != "{}" && !pending_json.is_empty() {
|
// 陈旧过滤根因修复(2026-08,治"审批待办关不了"):
|
||||||
if let Ok(restored) = serde_json::from_str::<
|
// 对话 pending_approvals JSON 列与 ai_tool_executions 审计表是**两份独立持久化**——
|
||||||
std::collections::HashMap<String, crate::commands::ai::PendingApproval>
|
// 审批处理时(ai_approve/ai_authorize_dir)若 remove 返 None(内存与 JSON 列不同步 / 恢复竞态),
|
||||||
>(pending_json) {
|
// 走 None 分支不 save → JSON 列不被修剪 → 残留陈旧条目(completed/rejected/failed 的)。
|
||||||
let restored_count = restored.len();
|
// 切回此对话时本段会反复 restore 这些已落定条目,前端 ApprovalPopup/ToolCard 永久显挂起卡,
|
||||||
// 仅恢复本 conv 的条目(已按 conv_id 过滤写入,DB 快照天然是本 conv 的)
|
// 用户拒绝又走 None 分支死循环。修复:恢复前用审计表核对,仅恢复审计 status='pending' 的条目,
|
||||||
session.pending_approvals.extend(restored);
|
// 跳过已落定的陈旧条目(并即时修剪 JSON 列,防下次再恢复陈旧)。
|
||||||
tracing::info!(
|
let restored_pending: std::collections::HashMap<String, crate::commands::ai::PendingApproval> =
|
||||||
"切换对话 {} 恢复 {} 条挂起审批(来自 DB pending_approvals 列)",
|
if let Some(pending_json) = &record.pending_approvals {
|
||||||
conversation_id, restored_count
|
if pending_json != "{}" && !pending_json.is_empty() {
|
||||||
);
|
match serde_json::from_str::<
|
||||||
|
std::collections::HashMap<String, crate::commands::ai::PendingApproval>
|
||||||
|
>(pending_json) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"切换对话 {} 反序列化 pending_approvals 失败,原始内容前 200 字: {:?}",
|
||||||
|
conversation_id, &pending_json.chars().take(200).collect::<String>()
|
||||||
|
);
|
||||||
|
std::collections::HashMap::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(
|
std::collections::HashMap::new()
|
||||||
"切换对话 {} 反序列化 pending_approvals 失败,原始内容前 200 字: {:?}",
|
|
||||||
conversation_id, &pending_json.chars().take(200).collect::<String>()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
std::collections::HashMap::new()
|
||||||
|
};
|
||||||
|
// 仅恢复本 conv 的条目(已按 conv_id 过滤写入,DB 快照天然是本 conv 的)
|
||||||
|
session.pending_approvals.extend(restored_pending.clone());
|
||||||
|
if !restored_pending.is_empty() {
|
||||||
|
tracing::info!(
|
||||||
|
"切换对话 {} 恢复 {} 条挂起审批(来自 DB pending_approvals 列,待审计核对)",
|
||||||
|
conversation_id, restored_pending.len()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB)
|
// 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB)
|
||||||
drop(session);
|
drop(session);
|
||||||
|
|
||||||
|
// 审计核对 + 即时修剪:对每条恢复的 tool_call_id 查 ai_tool_executions,
|
||||||
|
// 审计已落定(completed/rejected/failed/interrupted/skipped_retry)→ 视为陈旧,从内存移除,
|
||||||
|
// 并累积"存活"集合用于回写修剪后的 JSON 列(根治:下次切回不再恢复陈旧)。
|
||||||
|
// 审计查询失败(DB 故障)保守信任内存已 extend 的条目(不因 DB 故障误删用户真挂起审批)。
|
||||||
|
// 仅当确实有恢复条目时才跑核对(空 HashMap 跳过,无开销)。
|
||||||
|
if !restored_pending.is_empty() {
|
||||||
|
let mut stale_ids: Vec<String> = Vec::new();
|
||||||
|
for tc_id in restored_pending.keys() {
|
||||||
|
match state.ai_tool_executions.find_by_tool_call_id(tc_id).await {
|
||||||
|
Ok(Some(rec)) => {
|
||||||
|
// pending = 真挂起(首次或重启恢复未决策),保留。其余落定态 = 陈旧。
|
||||||
|
if rec.status != "pending" {
|
||||||
|
stale_ids.push(tc_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
// 审计无记录但 JSON 列有 —— 数据异常(老库 / 手改 DB)。保守保留(不删),
|
||||||
|
// 信任前端审批流会处理;若确属脏数据,用户拒绝后经修复1的 emit 路径收敛。
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"切换对话 {} 审计核对 tool_call_id={} 失败(保守保留该条): {}",
|
||||||
|
conversation_id, tc_id, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !stale_ids.is_empty() {
|
||||||
|
// 从内存 pending_approvals 移除陈旧条目(短 lock retain)
|
||||||
|
{
|
||||||
|
let mut session = state.ai_session.lock().await;
|
||||||
|
let to_remove: Vec<String> = stale_ids.clone();
|
||||||
|
session.pending_approvals.retain(|k, _| !to_remove.contains(k));
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
"切换对话 {} 审计核对剔除 {} 条陈旧审批(已落定,从 pending_approvals 内存+JSON 列移除)",
|
||||||
|
conversation_id, stale_ids.len()
|
||||||
|
);
|
||||||
|
// 即时修剪 JSON 列:回写仅含存活条目的快照,根治下次切回再恢复陈旧。
|
||||||
|
// 取存活集合(原恢复集合减陈旧)的当前内存态重新序列化(若期间被其他 IPC 改动,以内存为准)。
|
||||||
|
let surviving_json = {
|
||||||
|
let session = state.ai_session.lock().await;
|
||||||
|
let conv_pending: std::collections::HashMap<&String, &crate::commands::ai::PendingApproval> = session
|
||||||
|
.pending_approvals
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, a)| a.conversation_id.as_deref() == Some(&conversation_id))
|
||||||
|
.collect();
|
||||||
|
serde_json::to_string(&conv_pending).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
};
|
||||||
|
// update_full 需读现 record(防覆盖期间其他字段被改),仅改 pending_approvals 列。
|
||||||
|
match state.ai_conversations.get_by_id(&conversation_id).await {
|
||||||
|
Ok(Some(mut rec)) => {
|
||||||
|
rec.pending_approvals = Some(surviving_json);
|
||||||
|
if let Err(e) = state.ai_conversations.update_full(&rec).await {
|
||||||
|
tracing::warn!(
|
||||||
|
"切换对话 {} 修剪 pending_approvals JSON 列失败(非阻断,下次核对再修): {}",
|
||||||
|
conversation_id, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"切换对话 {} 修剪 pending_approvals JSON 列时对话已不存在(跳过)",
|
||||||
|
conversation_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"切换对话 {} 修剪 pending_approvals JSON 列读对话失败(非阻断): {}",
|
||||||
|
conversation_id, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 历史会话切入且 title 空 → 后台触发标题重新生成(extract 即时兜底 + LLM 优化)。
|
// 历史会话切入且 title 空 → 后台触发标题重新生成(extract 即时兜底 + LLM 优化)。
|
||||||
// ensure 内 get_by_id title Some 判断防重复;无可用 provider 静默跳过(增强不阻塞切换)。
|
// ensure 内 get_by_id title Some 判断防重复;无可用 provider 静默跳过(增强不阻塞切换)。
|
||||||
if need_title_regen {
|
if need_title_regen {
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result<
|
|||||||
None => {
|
None => {
|
||||||
// 未装:回退静态 + hint。
|
// 未装:回退静态 + hint。
|
||||||
let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
|
let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
|
||||||
fallback["render_mode"] = json!("static_fallback");
|
fallback["render_mode"] = json!("obscura_not_installed");
|
||||||
fallback["hint"] = json!(OBSCURA_HINT);
|
fallback["hint"] = json!(OBSCURA_HINT);
|
||||||
fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
|
fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
|
||||||
return Ok(fallback);
|
return Ok(fallback);
|
||||||
@@ -277,7 +277,7 @@ async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result<
|
|||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
tracing::warn!("obscura spawn 失败({}),回退静态", e);
|
tracing::warn!("obscura spawn 失败({}),回退静态", e);
|
||||||
let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
|
let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
|
||||||
fallback["render_mode"] = json!("static_fallback");
|
fallback["render_mode"] = json!("obscura_failed");
|
||||||
fallback["hint"] = json!(format!("obscura 启动失败({}),已用静态模式。{}", e, OBSCURA_HINT));
|
fallback["hint"] = json!(format!("obscura 启动失败({}),已用静态模式。{}", e, OBSCURA_HINT));
|
||||||
fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
|
fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
|
||||||
return Ok(fallback);
|
return Ok(fallback);
|
||||||
@@ -285,7 +285,7 @@ async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result<
|
|||||||
Err(_) => {
|
Err(_) => {
|
||||||
tracing::warn!("obscura 超时({}s),回退静态", OBSCURA_TIMEOUT_SECS);
|
tracing::warn!("obscura 超时({}s),回退静态", OBSCURA_TIMEOUT_SECS);
|
||||||
let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
|
let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
|
||||||
fallback["render_mode"] = json!("static_fallback");
|
fallback["render_mode"] = json!("obscura_failed");
|
||||||
fallback["hint"] = json!(format!("obscura 渲染超时({}s),已用静态模式。{}", OBSCURA_TIMEOUT_SECS, OBSCURA_HINT));
|
fallback["hint"] = json!(format!("obscura 渲染超时({}s),已用静态模式。{}", OBSCURA_TIMEOUT_SECS, OBSCURA_HINT));
|
||||||
fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
|
fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
|
||||||
return Ok(fallback);
|
return Ok(fallback);
|
||||||
@@ -362,7 +362,7 @@ fn find_obscura() -> Option<String> {
|
|||||||
let out = c.output();
|
let out = c.output();
|
||||||
let _ = tx.send(out);
|
let _ = tx.send(out);
|
||||||
});
|
});
|
||||||
match rx.recv_timeout(Duration::from_secs(5)) {
|
match rx.recv_timeout(Duration::from_secs(15)) {
|
||||||
Ok(Ok(o)) if o.status.success() => return Some(cand.to_string()),
|
Ok(Ok(o)) if o.status.success() => return Some(cand.to_string()),
|
||||||
_ => continue,
|
_ => continue,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@
|
|||||||
<div class="ai-tool-fetch-line">
|
<div class="ai-tool-fetch-line">
|
||||||
<span class="ai-tool-http-method ai-tool-fetch-mode" :class="'ai-tool-fetch-mode--' + (parsed?.render_mode || 'static')">
|
<span class="ai-tool-http-method ai-tool-fetch-mode" :class="'ai-tool-fetch-mode--' + (parsed?.render_mode || 'static')">
|
||||||
{{ parsed?.render_mode === 'obscura' ? $t('aiTool.fetchUrlRendered')
|
{{ parsed?.render_mode === 'obscura' ? $t('aiTool.fetchUrlRendered')
|
||||||
: parsed?.render_mode === 'static_fallback' ? $t('aiTool.fetchUrlStatic')
|
: parsed?.render_mode === 'obscura_failed' ? $t('aiTool.fetchUrlFallback')
|
||||||
: $t('aiTool.fetchUrlStatic') }}
|
: $t('aiTool.fetchUrlStatic') }}
|
||||||
</span>
|
</span>
|
||||||
<code class="ai-tool-http-url">{{ parsed?.url }}</code>
|
<code class="ai-tool-http-url">{{ parsed?.url }}</code>
|
||||||
@@ -309,19 +309,16 @@ const grepHitLabel = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fetch_url obscura 未装检测(render=true 但回退静态):
|
* fetch_url obscura 未装检测(render=true 但 obscura 未装):
|
||||||
* 后端 render=true 且 obscura 未装/失败/超时 → result.render_mode='static_fallback' + hint 含 obscura 引导。
|
* 后端 render=true 且 obscura 未装 → result.render_mode='obscura_not_installed' + hint 引导。
|
||||||
* 此 computed 供模板显"AI 帮装"按钮 + obscura 未装提示。
|
* 装了但失败/超时 → 'obscura_failed'(不弹引导,只 hint)。此 computed 仅未装弹"AI 帮装"。
|
||||||
* 检测条件:render_mode==='static_fallback'(后端保证此值仅 render 分支回退时设,普通静态是 'static')。
|
|
||||||
* 双重确认 hint 含 obscura(防御后端未来改字段名),任一命中即视为未装。
|
|
||||||
*/
|
*/
|
||||||
const obscuraMissing = computed(() => {
|
const obscuraMissing = computed(() => {
|
||||||
if (props.tc.name !== 'fetch_url') return false
|
if (props.tc.name !== 'fetch_url') return false
|
||||||
const r = parsed.value
|
const r = parsed.value
|
||||||
if (!r) return false
|
if (!r) return false
|
||||||
const mode = typeof r.render_mode === 'string' ? r.render_mode : ''
|
const mode = typeof r.render_mode === 'string' ? r.render_mode : ''
|
||||||
const hint = typeof r.hint === 'string' ? r.hint : ''
|
return mode === 'obscura_not_installed'
|
||||||
return mode === 'static_fallback' || hint.toLowerCase().includes('obscura')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user