修复: 审批挂起关不了(None分支补emit+恢复对账) + obscura检测区分未装/失败(防误弹)
This commit is contained in:
@@ -521,19 +521,36 @@ pub async fn ai_approve(
|
||||
// F-260616-06: 幂等——内存无挂起审批时查审计表,若已处理则返回成功(非报错)
|
||||
// BUG-260618-11: 区分 Err(DB 故障)与 Ok(None)(真无记录),原 unwrap_or_default 把
|
||||
// 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)) => {
|
||||
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));
|
||||
}
|
||||
rec.status // 其他状态(如 pending/interrupted/skipped_retry)继续走未找到报错
|
||||
}
|
||||
Ok(None) => { /* 真无记录,落到下方 return Err 未找到挂起审批 */ }
|
||||
Ok(None) => { String::new() /* 真无记录,落到下方 return Err 未找到挂起审批 */ }
|
||||
Err(e) => {
|
||||
tracing::error!("ai_approve: 查询 tool_call_id={} 审计记录失败(DB 故障): {}", tool_call_id, 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)。
|
||||
@@ -879,7 +896,29 @@ pub async fn ai_authorize_dir(
|
||||
let mut session = state.ai_session.lock().await;
|
||||
let v = match session.pending_approvals.remove(&tool_call_id) {
|
||||
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();
|
||||
if __hold864 > std::time::Duration::from_millis(30) {
|
||||
|
||||
@@ -379,29 +379,122 @@ pub async fn ai_conversation_switch(
|
||||
|
||||
// 从 DB 恢复本对话的挂起审批快照(重启/切回时,之前 save_conversation 持久化的 pending_approvals)
|
||||
// 反序列化后插入 session.pending_approvals,使 ai_pending_tool_calls 可查回、审批卡片恢复。
|
||||
if let Some(pending_json) = &record.pending_approvals {
|
||||
if pending_json != "{}" && !pending_json.is_empty() {
|
||||
if let Ok(restored) = serde_json::from_str::<
|
||||
std::collections::HashMap<String, crate::commands::ai::PendingApproval>
|
||||
>(pending_json) {
|
||||
let restored_count = restored.len();
|
||||
// 仅恢复本 conv 的条目(已按 conv_id 过滤写入,DB 快照天然是本 conv 的)
|
||||
session.pending_approvals.extend(restored);
|
||||
tracing::info!(
|
||||
"切换对话 {} 恢复 {} 条挂起审批(来自 DB pending_approvals 列)",
|
||||
conversation_id, restored_count
|
||||
);
|
||||
//
|
||||
// 陈旧过滤根因修复(2026-08,治"审批待办关不了"):
|
||||
// 对话 pending_approvals JSON 列与 ai_tool_executions 审计表是**两份独立持久化**——
|
||||
// 审批处理时(ai_approve/ai_authorize_dir)若 remove 返 None(内存与 JSON 列不同步 / 恢复竞态),
|
||||
// 走 None 分支不 save → JSON 列不被修剪 → 残留陈旧条目(completed/rejected/failed 的)。
|
||||
// 切回此对话时本段会反复 restore 这些已落定条目,前端 ApprovalPopup/ToolCard 永久显挂起卡,
|
||||
// 用户拒绝又走 None 分支死循环。修复:恢复前用审计表核对,仅恢复审计 status='pending' 的条目,
|
||||
// 跳过已落定的陈旧条目(并即时修剪 JSON 列,防下次再恢复陈旧)。
|
||||
let restored_pending: std::collections::HashMap<String, crate::commands::ai::PendingApproval> =
|
||||
if let Some(pending_json) = &record.pending_approvals {
|
||||
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 {
|
||||
tracing::warn!(
|
||||
"切换对话 {} 反序列化 pending_approvals 失败,原始内容前 200 字: {:?}",
|
||||
conversation_id, &pending_json.chars().take(200).collect::<String>()
|
||||
);
|
||||
std::collections::HashMap::new()
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
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 优化)。
|
||||
// ensure 内 get_by_id title Some 判断防重复;无可用 provider 静默跳过(增强不阻塞切换)。
|
||||
if need_title_regen {
|
||||
|
||||
@@ -248,7 +248,7 @@ async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result<
|
||||
None => {
|
||||
// 未装:回退静态 + hint。
|
||||
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["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
|
||||
return Ok(fallback);
|
||||
@@ -277,7 +277,7 @@ async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result<
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!("obscura spawn 失败({}),回退静态", e);
|
||||
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["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
|
||||
return Ok(fallback);
|
||||
@@ -285,7 +285,7 @@ async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result<
|
||||
Err(_) => {
|
||||
tracing::warn!("obscura 超时({}s),回退静态", OBSCURA_TIMEOUT_SECS);
|
||||
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["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
|
||||
return Ok(fallback);
|
||||
@@ -362,7 +362,7 @@ fn find_obscura() -> Option<String> {
|
||||
let out = c.output();
|
||||
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()),
|
||||
_ => continue,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user