重构: F-09 batch8 启动恢复多conv restore + L0清残留(纯后端,F-09后端完整)

- audit.rs restore_pending_approvals: 多conv分配(按conversation_id分组惰性建/复用PerConvState,无主None不建R-9) + 2单测(分配不变量/全None无per_conv)
- lib.rs L0握手: 遍历per_conv清多conv残留generating(dirty_convs+逐个复位+各发AiCompleted补偿) + active兜底
与双写兼容(顶层双写复位),不改IPC签名/前端/顶层字段
主代兜底: cargo check --workspace 0 + test 98 + grep restore多conv/L0遍历印证
F-09后端完整: batch1 PerConvState + batch2 per-conv迁移 + batch5 限流 + batch8 恢复; batch4(启用·前后端)待用户
This commit is contained in:
2026-06-19 02:26:24 +08:00
parent 5c15b72d3d
commit 6ad4ec2848
2 changed files with 178 additions and 30 deletions

View File

@@ -280,6 +280,14 @@ async fn build_approval_reason(
/// pending_approvals 是 AiSession 内存 HashMap,重启必丢。ai_tool_executions 表已存 /// pending_approvals 是 AiSession 内存 HashMap,重启必丢。ai_tool_executions 表已存
/// status=pending 的行(持久化真相源),此处读回重建内存态,使重启后待审批不丢。 /// status=pending 的行(持久化真相源),此处读回重建内存态,使重启后待审批不丢。
/// 前端经 ai_pending_tool_calls 查询 + switchConversation 恢复 toolCard 的 pending_approval 态。 /// 前端经 ai_pending_tool_calls 查询 + switchConversation 恢复 toolCard 的 pending_approval 态。
///
/// **F-260616-09 B 批8 多 conv 适配(设计 §3 batch8 + §5.2)**:DB pending 审批按
/// `conversation_id` 分配到各 conv 的 per_conv state——对每个含 pending 审批的 conv
/// **惰性建 `PerConvState`**(不依赖 `active_conversation_id` 单值),使后续 ai_approve →
/// try_continue_agent_loop 的 `conv_read(conv_id)` 命中各自 per_conv(各归各,不串)。
/// `pending_approvals` 本身保持单层 HashMap(设计 §2.1.2,已带 conversation_id 是业务
/// 语义而非路由键)。conversation_id=None 的无主审批(R-9)不建 per_conv(无 conv_id 可挂),
/// 仍进 pending_approvals 单层表,后续审批按 tool_call_id 路由,不影响正确性。
pub async fn restore_pending_approvals(state: &AppState) { pub async fn restore_pending_approvals(state: &AppState) {
let pending = match state.ai_tool_executions.list_pending().await { let pending = match state.ai_tool_executions.list_pending().await {
Ok(v) => v, Ok(v) => v,
@@ -292,12 +300,27 @@ pub async fn restore_pending_approvals(state: &AppState) {
return; return;
} }
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
// 多 conv 分配:先建 conv → per_conv 索引(惰性建 PerConvState::new),再 insert pending。
// conv() 已自处理「已存在则复用」(同 conv_id 多条 pending 复用同一 PerConvState),
// 故对每条 pending 都调一次 conv() 是幂等的(后续命中 entry().or_insert_with 跳过新建)。
let mut convs_restored: std::collections::HashSet<String> = std::collections::HashSet::new();
for rec in pending { for rec in pending {
let args: serde_json::Value = serde_json::from_str(&rec.arguments).unwrap_or_default(); let args: serde_json::Value = serde_json::from_str(&rec.arguments).unwrap_or_default();
// 过滤 risk_level 解析失败的损坏记录(语义保留:数据异常不恢复)·不再绑定 risk(PendingApproval.risk_level 已删) // 过滤 risk_level 解析失败的损坏记录(语义保留:数据异常不恢复)·不再绑定 risk(PendingApproval.risk_level 已删)
if risk_from_str(&rec.risk_level).is_none() { if risk_from_str(&rec.risk_level).is_none() {
continue; continue;
} }
// 多 conv 分配:有 conversation_id 的审批惰性建/复用对应 conv 的 per_conv(设计 §3 batch8)。
// 无 conversation_id 的无主审批(R-9)不建 per_conv——无 conv_id 可挂,仍进 pending_approvals
// 单层表按 tool_call_id 路由,后续 ai_approve 路径不依赖其 per_conv。
if let Some(cid) = rec.conversation_id.as_deref() {
if !cid.is_empty() && convs_restored.insert(cid.to_string()) {
// 惰性建:无则建 PerConvState::new,有则 or_insert_with 跳过(幂等,无副作用)。
// 不在此恢复 messages——messages 由 switchConversation 从 DB reload 独立路径恢复,
// 此处仅保证 conv 在 per_conv 中存在(供 conv_read 命中)。
let _ = session.conv(cid);
}
}
session.pending_approvals.insert( session.pending_approvals.insert(
rec.tool_call_id.clone(), rec.tool_call_id.clone(),
PendingApproval { PendingApproval {
@@ -314,7 +337,11 @@ pub async fn restore_pending_approvals(state: &AppState) {
}, },
); );
} }
tracing::info!("启动恢复: {} 条 pending 工具审批重建到内存", session.pending_approvals.len()); tracing::info!(
"启动恢复: {} 条 pending 工具审批重建到内存, 分布 {} 个 conv 的 per_conv",
session.pending_approvals.len(),
convs_restored.len()
);
} }
/// 写一条工具执行审计记录insert 失败不阻断主流程,故 `let _ =` /// 写一条工具执行审计记录insert 失败不阻断主流程,故 `let _ =`
@@ -828,3 +855,105 @@ pub(crate) async fn process_tool_calls(
pending_count pending_count
} }
#[cfg(test)]
mod tests_f09_batch8_restore {
use super::*;
use crate::commands::ai::{AiSession, PendingApproval};
/// F-260616-09 B 批8(设计 §3 batch8 + §5.2):验证 restore_pending_approvals 的多 conv 分配不变量。
///
/// restore_pending_approvals 受限于 AppState(需 DB),无法直接单测。但其核心分配逻辑
/// 「对每个 conversation_id=Some 的恢复审批,惰性建/复用对应 conv 的 PerConvState」依赖
/// 的契约是 AiSession::conv 的幂等惰性创建 + conv_read 可达。本测试用同一契约重现分配
/// 逻辑,验证各 conv 各归各(不串)、无主审批(None)不建 per_conv。
#[test]
fn test_restore_multi_conv_distribution_invariant() {
let mut session = AiSession::new();
// 模拟 DB pending 行:3 条分属 2 个 conv(conv-a 两条 + conv-b 一条)+ 1 条 None 无主。
let pending_rows: Vec<(String, String, Option<String>)> = vec![
("tc-1".to_string(), "write_file".to_string(), Some("conv-a".to_string())),
("tc-2".to_string(), "run_command".to_string(), Some("conv-a".to_string())),
("tc-3".to_string(), "delete_project".to_string(), Some("conv-b".to_string())),
("tc-4".to_string(), "create_task".to_string(), None), // 无主(R-9)
];
// 复现 restore_pending_approvals 的分配逻辑(同名变量,逐字对齐实现)。
let mut convs_restored: HashSet<String> = HashSet::new();
for (tool_call_id, tool_name, conversation_id) in &pending_rows {
if let Some(cid) = conversation_id.as_deref() {
if !cid.is_empty() && convs_restored.insert(cid.to_string()) {
let _ = session.conv(cid); // 惰性建
}
}
// pending 入单层 HashMap(不进 per_conv,设计 §2.1.2)。
session.pending_approvals.insert(
tool_call_id.clone(),
PendingApproval {
tool_call_id: tool_call_id.clone(),
tool_name: tool_name.clone(),
arguments: serde_json::Value::Null,
conversation_id: conversation_id.clone(),
recovered: true,
diff: None,
},
);
}
// 不变量 1:per_conv 含 conv-a + conv-b 各一份(惰性建,无主 None 不建)。
assert_eq!(session.per_conv.len(), 2, "2 个 conv 各惰性建 1 份 per_conv");
assert!(session.conv_read("conv-a").is_some(), "conv-a 恢复后 conv_read 应可达");
assert!(session.conv_read("conv-b").is_some(), "conv-b 恢复后 conv_read 应可达");
assert!(
session.conv_read("never-restored").is_none(),
"未恢复的 conv 不应被惰性建"
);
// 不变量 2:pending_approvals 单层 HashMap 4 条(含无主),conversation_id 保留(业务语义)。
assert_eq!(session.pending_approvals.len(), 4, "全部 pending 入单层 HashMap");
let conv_a_pending: Vec<_> = session
.pending_approvals
.values()
.filter(|a| a.conversation_id.as_deref() == Some("conv-a"))
.collect();
assert_eq!(conv_a_pending.len(), 2, "conv-a 2 条 pending 各归各");
let conv_b_pending: Vec<_> = session
.pending_approvals
.values()
.filter(|a| a.conversation_id.as_deref() == Some("conv-b"))
.collect();
assert_eq!(conv_b_pending.len(), 1, "conv-b 1 条 pending");
let ownerless: Vec<_> = session
.pending_approvals
.values()
.filter(|a| a.conversation_id.is_none())
.collect();
assert_eq!(ownerless.len(), 1, "无主审批 1 条仍入单层表(R-9)");
// 不变量 3:同 conv 多条 pending 复用同一 PerConvState(conv() 幂等,不重复建)。
let conv_a_ptr = session.conv("conv-a") as *const _;
let _ = session.conv("conv-a"); // 再次访问(模拟同 conv 二次 pending)
let conv_a_ptr_again = session.conv("conv-a") as *const _;
assert_eq!(conv_a_ptr, conv_a_ptr_again, "同 conv 复用同一 PerConvState(幂等)");
assert_eq!(session.per_conv.len(), 2, "同 conv 二次访问不新增 per_conv 条目");
}
/// 验证 restore 仅含无主审批(None)时不建任何 per_conv(R-9 边界)。
#[test]
fn test_restore_only_ownerless_no_per_conv() {
let mut session = AiSession::new();
let mut convs_restored: HashSet<String> = HashSet::new();
// 全部 None 的 pending 行:无主,不应触发任何 conv() 惰性建。
let pending_rows: Vec<Option<String>> = vec![None, None, None];
for conversation_id in &pending_rows {
if let Some(cid) = conversation_id.as_deref() {
if !cid.is_empty() && convs_restored.insert(cid.to_string()) {
let _ = session.conv(cid);
}
}
}
assert!(session.per_conv.is_empty(), "仅无主审批不应建任何 per_conv");
assert!(convs_restored.is_empty(), "无 conv 被记录为已恢复");
}
}

View File

@@ -40,37 +40,56 @@ pub fn run() {
let app_h = app_handle.clone(); let app_h = app_handle.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
// F-260616-09 B 批2:读 active conv 的 per_conv.generating + 双写复位。 // F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv
// conv_id 来源:active_conversation_id(HMR 重连场景的当前展示 conv)。 // 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。
let conv_id = session.active_conversation_id.clone(); // 批2 前:仅读 active per_conv.generating 单值;批8 改遍历全部 per_conv 各归各复位。
let active_for_check = conv_id.clone().unwrap_or_default(); // 顶层 session.generating(批2 双写桥接)同步复位(批4 IPC 迁移后顶层移除)。
let was_generating = if active_for_check.is_empty() { let mut dirty_convs: Vec<String> = session
session.generating .per_conv
} else { .iter()
session.conv_read(&active_for_check).map(|c| c.generating).unwrap_or(session.generating) .filter(|(_, c)| c.generating)
}; .map(|(id, _)| id.clone())
if was_generating { .collect();
if !active_for_check.is_empty() { let was_generating = !dirty_convs.is_empty() || session.generating;
session.conv(&active_for_check).generating = false; // 复位每个残留 generating 的 conv(批8 多 conv 全覆盖)。
for cid in &dirty_convs {
if let Some(conv) = session.per_conv.get_mut(cid) {
conv.generating = false;
} }
session.generating = false;
session.pending_approvals.clear();
drop(session);
// 补偿事件:通知前端收尾(复位 streaming / generatingConvId
let _ = app_h.emit(
"ai-chat-event",
commands::ai::AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: conv_id,
},
);
} else {
session.pending_approvals.clear();
} }
tracing::info!("[L0-handshake] 前端重连握手完成, was_generating={}", was_generating); // 顶层双写复位(批2 桥接:IPC ai_is_generating 等仍读顶层,须同步)。
session.generating = false;
session.pending_approvals.clear();
// active_conversation_id 兜底入 dirty_convs:批2 双写期顶层 generating=true 但
// per_conv 为空(理论上不会发生,防御性保证 active conv 至少收一个 AiCompleted
// 复位前端 streaming/generatingConvId)。
if let Some(active) = session.active_conversation_id.clone() {
if !active.is_empty() && !dirty_convs.contains(&active) {
dirty_convs.push(active);
}
}
drop(session);
if was_generating {
// 补偿事件:每个残留 conv 各发一个 AiCompleted(按 conversation_id 路由),
// 前端 useAiEvents.ts:133-140 按 conv_id 各归各复位 streaming/generatingConvId。
for cid in &dirty_convs {
let _ = app_h.emit(
"ai-chat-event",
commands::ai::AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: Some(cid.clone()),
},
);
}
}
tracing::info!(
"[L0-handshake] 前端重连握手完成, was_generating={}, dirty_convs={:?}",
was_generating,
dirty_convs
);
}); });
}); });