From 93172a6844fbf52f7c49dac71964b17bf423f4c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Wed, 24 Jun 2026 22:13:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E6=97=A0=E5=BF=83=E8=B7=B3=E8=87=B4=E6=B5=81?= =?UTF-8?q?=E4=B8=AD=E6=96=AD=E8=AF=AF=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/todo.md | 9 +++- src-tauri/src/commands/ai/audit/mod.rs | 58 +++++++++++++++++++++++++- src/composables/ai/useAiStream.ts | 17 ++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index ce91b7a..c0be0dc 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -947,7 +947,7 @@ --- -### ✅ 2026-06-24 aichat 可靠性修复(消息重叠×5 + 授权弹窗卡死 · 诊断workflow→论证workflow) +### ✅ 2026-06-24 aichat 可靠性修复(消息重叠×5 + 授权弹窗卡死 + 工具执行心跳误报 · 诊断workflow→论证workflow) > 用户「从历史消息找需求逐一比对」→ 捞出反复报告未根治的可靠性 bug。**诊断 workflow(4 agent 只读核验)** 定根因 → **论证 workflow(4 角度:正确性/回归/完整性/边界)** 验正确性+完整性 → 补 2 处(完整性高优+回归中优)。vue-tsc 0。 > @@ -961,6 +961,13 @@ > > **诊断推翻的误判**(anti-contamination 印证):压缩「未降prompt」= 指标口径(accumulate_tokens 跨轮累计非单轮;压缩真省,is_active 白名单剔除 compressed)✅ / generating 状态机 = 已落地(RAII guard guard.rs + enum 视图 session_state + per_conv,原创建对话硬拦已删)✅。task #120 关闭。 > +> **BUG-260624-03 工具执行心跳误报根治**(用户 b1f644a7「一边 aichat 回复着内容,一边抛"工具已执行完成,后续回复中断"」): +> - 根因(论证 workflow 4 维度·7 条源码证据闭环):前端 watchdog 130s 无活跃事件→判断断流→抛错;后端 AiHeartbeat 30s 静默期 emit 让前端 reset,但**心跳只在 `stream_llm`(provider 读 chunk 的 select! 循环,stream_recv.rs:333)内,工具执行在 audit/mod.rs(stream_llm 外)无心跳**。单次工具执行>130s(bash 跑 cargo/测试、read 大文件、全盘 search)→ watchdog 到期→该 conv 仍 generatingConvs→守卫放行→前轮有 completed 工具即命中"工具已执行完成后续中断"文案;前一轮 delta 文本在屏 = "一边流一边抛"。**反复修未根治因都在前端守卫打补丁(B-260615-03 文案/BUG-260623-06 MaxRounds clear/TD-260621-01 残留 timer),没人补工具执行阶段的心跳缺口**。 +> - 修复(A 根治 + B 兜底,论证质量 A 可发布): +> - **A 后端根治**(audit/mod.rs):新增 `execute_with_heartbeat` helper(execute 期间 30s interval emit AiHeartbeat,弃首 tick 对齐 stream_recv:174;**RAII HeartbeatGuard** drop 自动 stop+abort,panic unwind 也不泄漏 task),替换 trust_hits/low_risk 两处 `tools.execute()`。补工具执行阶段心跳缺口。 +> - **B 前端兜底**(useAiStream onStreamTimeout):stillGenerating 守卫后扫 messages running 工具卡→resetStreamWatchdog 续等不抛错(防心跳丢失/老后端/审批后路径误杀)。审批路径(chat.rs:575/877)非盲区——已包 60s timeout<130s watchdog 互补治理(论证勘误维度3 误判)。 +> - 验证:cargo check exit 0(RAII 编译过)+ vue-tsc 0。B 维度因网络错未跑,已自核验 ToolCard approving 计时器仅复位按钮 loading 非 running(running 由后端 AiToolCallCompleted 驱动),真死锁续等优于误报(用户可 stop),A 心跳覆盖正常执行。 +> > **剩余低优 [ ](非阻断)**: > - [x] ✅(2026-06-24·ebba973) BUG-260624-01 残留:① MessageList:503 scrollToFirstPending return 过早修复(遍历全实例调,各内部 querySelector 无 pending 则 no-op,治 pending 落后续实例滚不到)+ ② ToolCardList listener 加 props.toolCalls.some(pending) 过滤短路(降大列表 N 实例×M emit 的 DOM 查询)。**scrollToFirstPending 时间窗去抖评估后不做**:同 id 连发被上游守卫拦截(AiDirAuthRequired/AiApprovalRequired useAiEvents:359/472 some 去重 + AiToolCallStarted:427 findToolCall 守卫),不同 id 连发是多 pending 合理场景每次滚到首个合理,vue-tsc EXIT 0。 > - [ ] BUG-260624-02 残留:审批超时倒计时 UI(5min 无倒计时,ToolCard pending 态显 mm:ss 到期前 60s 变红)+ 核 detached 窗口 emit 作用域(分离窗口是否独立 startListener,否则 ai-pending-arrived 收不到) diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index 2e3466a..bb80e6f 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -248,6 +248,60 @@ async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id: &str) -> u3 /// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控 /// 返回待审批的工具数量(0 = 全部自动执行完成) +/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs +/// stream_llm select! 心跳语义),execute 完 abort 心跳 task。 +/// +/// 根治 BUG-260624-03:工具执行在 stream_llm 之外(本模块),原本无 AiHeartbeat。 +/// 单次 execute 超过前端 STREAM_TIMEOUT_MS(130s)——bash 跑 cargo/测试、read 大文件、 +/// 全盘 search 等开发长命令——前端 watchdog 误判断流,抛"工具已执行完成后续中断"误报 +/// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。 +/// 补工具执行阶段的心跳缺口,前端 watchdog 在静默期也能收到 reset,不再误杀长工具。 +async fn execute_with_heartbeat( + tools: &AiToolRegistry, + name: &str, + args: serde_json::Value, + app: &AppHandle, + conv_id: &str, +) -> anyhow::Result { + use std::sync::atomic::{AtomicBool, Ordering}; + use tokio::time::Duration; + + // RAII guard:drop 时 stop+abort 心跳 task,确保 execute panic(unwind)也不泄漏心跳 task。 + struct HeartbeatGuard { + stop: Arc, + handle: tokio::task::JoinHandle<()>, + } + impl Drop for HeartbeatGuard { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + self.handle.abort(); + } + } + + let stop = Arc::new(AtomicBool::new(false)); + let app_h = app.clone(); + let conv = conv_id.to_string(); + let stop_c = stop.clone(); + // 心跳 task:弃首 tick(tokio interval 首 tick 立即返回,对齐 stream_recv.rs:174), + // 后每 30s emit AiHeartbeat(< 130s watchdog 确保覆盖)。 + let heartbeat = tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.tick().await; + loop { + interval.tick().await; + if stop_c.load(Ordering::SeqCst) { + break; + } + let ev = AiChatEvent::AiHeartbeat { conversation_id: Some(conv.clone()) }; + let _ = app_h.emit("ai-chat-event", ev.clone()); + let _ = app_h.state::().ai_event_bus.publish_event(ev); + } + }); + // RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。 + let _guard = HeartbeatGuard { stop, handle: heartbeat }; + tools.execute(name, args).await +} + pub(crate) async fn process_tool_calls( session: &mut AiSession, tool_calls_acc: HashMap, @@ -601,7 +655,7 @@ pub(crate) async fn process_tool_calls( let app_clone = app_handle.clone(); let conv_clone = conv_id.to_string(); async move { - let exec_result = tools.execute(&draft.name, args).await; + let exec_result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone).await; match exec_result { Ok(val) => { let content = val.to_string(); @@ -658,7 +712,7 @@ pub(crate) async fn process_tool_calls( let app_clone = app_handle.clone(); let conv_clone = conv_id.to_string(); async move { - let result = tools.execute(&draft.name, args).await; + let result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone).await; match result { Ok(val) => { // L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。 diff --git a/src/composables/ai/useAiStream.ts b/src/composables/ai/useAiStream.ts index 976aa21..dfe97b9 100644 --- a/src/composables/ai/useAiStream.ts +++ b/src/composables/ai/useAiStream.ts @@ -60,6 +60,23 @@ export function onStreamTimeout(convId?: string) { clearStreamWatchdog(convId) return } + // 兜底守卫(BUG-260624-03):工具执行中(running 卡)不计整流超时。 + // 后端 execute_with_heartbeat(audit/mod.rs)已为工具执行补 30s AiHeartbeat,正常执行 + // 不会到 130s 超时;到此且仍有 running 卡 = 心跳也丢失(老后端/provider 双重异常), + // 静默续等(reset 重计 130s)给工具完成/心跳恢复机会,不抛"工具已执行完成后续中断"误报。 + // 注:真死锁(running 永驻)时续等会持续,但优于误报流中断(forceReset 致状态不一致); + // running 状态由后端 AiToolCallCompleted 驱动(非 ToolCard approving 计时器,那仅复位按钮 loading), + // 用户可手动 stop,且 A 心跳已覆盖正常执行,真死锁罕见。 + for (let i = state.messages.length - 1; i >= 0; i--) { + const toolCalls = state.messages[i].toolCalls + if (!toolCalls) continue + for (let j = 0; j < toolCalls.length; j++) { + if (toolCalls[j].status === 'running') { + resetStreamWatchdog(convId) + return + } + } + } // 卡死兜底走 guard 的 forceResetStreaming(复位 streaming + 清 currentText/queue + warn 日志)。 // TD-260621-01:携带 convId 时仅清该 conv 的 generatingConvs(per-conv);不传时全局兜底不动 Set。 forceResetStreaming('onStreamTimeout(130s 无数据)', convId)