diff --git a/Cargo.lock b/Cargo.lock index 503fd9b..b40e5ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -779,6 +779,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -835,6 +849,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "chrono", + "dashmap", "df-ai", "df-execute", "df-ideas", @@ -877,9 +892,9 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "bytes", "df-ai-core", "df-types", - "eventsource-stream", "futures", "rand 0.8.6", "reqwest 0.12.28", @@ -1295,17 +1310,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "eventsource-stream" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" -dependencies = [ - "futures-core", - "nom", - "pin-project-lite", -] - [[package]] name = "fallible-iterator" version = "0.3.0" @@ -2553,12 +2557,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2661,16 +2659,6 @@ dependencies = [ "memoffset", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" diff --git a/Cargo.toml b/Cargo.toml index 626ddde..d118248 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,7 @@ regex = "1" # Win+mac 启用原生后端;linux 用 async-persistent 变体避同步阻塞 Secret Service。 # 由 df-storage(密钥解析下沉层) + src-tauri(build_provider_for 转发壳)共同引用。 keyring = { version = "3", features = ["windows-native", "apple-native", "linux-native-async-persistent"] } +# dashmap: ConvStateStore 无锁并发 HashMap(session 锁重构方案 B-Phase0)。 +# ConvState 提到 ConvStateStore(DashMap),guard.reset/ai_is_generating 不依赖 session lock, +# 治卡死连环(AiCompleted 延迟/工具后中断/第二条进队列同源根因)。详见 workflow session-lock-redesign。 +dashmap = "6" diff --git a/PROGRESS.md b/PROGRESS.md index db42174..fdbe3c6 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1031,17 +1031,46 @@ --- -### 2026-06-28 — 最新对话 DB & Log 诊断:审批死锁 + Windows 兼容 + 标题 LLM 失败 +### 2026-07-19 — ConvStateStore 无锁重构 + 前端 P0 修复 + DeepSeek 400 修复 + 标题策略改进 -> 分析来源:`devflow-trace.log` + `devflow-dev.db` 最新对话 `d9aef24f`(语音输入应用需求)。 -> 7 次工具连续失败后 L1 断路器熔断,1 条 pending 审批导致全链路死锁。 +> 本轮(Zed Agent 接管)完成 B-Phase 全量迁移、前端 P0、多轮 bug 修复、代码审查清理。 +> 代码变更 50 文件,+2454/-1251 行,未提交。 -- [ ] **P0 — 审批 pending 超时自动取消**:`try_continue` 入口清理 >5min 的 pending。 -- [ ] **P0 — `create_project` 加 auto_create_dir**:消除建项目→建目录→绑定的死锁链。 -- [ ] **P1 — `run_command` 失败追加 shell 适配提示**:引导 LLM 改正 PowerShell 命令。 -- [ ] **P1 — PowerShell 反斜杠自动转义**:消除 `os error 123`(Unicode 转义误识别)。 -- [ ] **P2 — 标题摘要合并连续同 role**:避免标题 LLM 的 GLM 1214 拒绝。 -- [ ] **P3 — migration V33 + L0-handshake 防抖**。 +**B-Phase ConvStateStore 无锁重构(根治 session lock 竞争)** + +| Phase | 内容 | +|---|---| +| Phase0-1 | DashMap 骨架 + AppState 接入(Claude Code 完成)| +| Phase2 | guard/L0/chat IPC 写侧全量切 conv_states | +| Phase3 | 读侧迁移 + 删 PerConvState.conv_state 字段 → 单源收敛 | +| Phase4 | conversation_delete 同步 conv_states.remove | + +**前端修复** +- P0-1: watchdog `convStates.clear()` → `delete(activeConversationId)` +- P0-2: switch/deleteConversation 入口加 `clearAllApprovalTimers()` + 注释修正 +- 每轮 token 独立显示(🔣 XXX in · YYY out) +- STREAM_TIMEOUT_MS 45s → 90s + +**后端修复** +- DeepSeek 400「insufficient tool messages」→ 末尾 orphan tool_calls 自动剥离 +- `env_snapshot.rs` 5s 超时兜底(Windows Store alias 卡死) +- L0 握手始终推 AiConvStateChanged{idle} 防前端状态残留 +- `openai_compat.rs` send 阶段 60s timeout +- 原生 SSE 解析器替代 eventsource-stream(Windows 兼容) +- run_agentic_loop 20s AiHeartbeat 保活 +- grep 正则自动修复(未分组 `|` → `(?:...)` 包裹) +- read_file 路径不存在提示 list_directory + +**标题策略改进** +- extract_title: 取前 5 条 user 消息中最长的一条(替代第一条截取) +- extract 不再落库 → LLM 每次都有重试机会,不再永久锁定差标题 + +**代码审查清理** +- 修复 lib.rs 重复 handler 注册(🔴 严重) +- 清理 unused import / [STUCK] 调试日志 / 过时注释 +- 340 tests passed, vue-tsc clean + +**待提交**:50 文件改动,含 B-Phase 重构 + 前端 P0 + 多轮 bug 修复 ## 七、开发约定 diff --git a/crates/df-ai/Cargo.toml b/crates/df-ai/Cargo.toml index c4b4079..6794041 100644 --- a/crates/df-ai/Cargo.toml +++ b/crates/df-ai/Cargo.toml @@ -14,9 +14,9 @@ anyhow = { workspace = true } tracing = { workspace = true } # HTTP + 流式 -reqwest = { version = "0.12", features = ["stream", "json"] } +reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] } +bytes = "1" futures = "0.3" -eventsource-stream = "0.2" rand = "0.8" [dev-dependencies] diff --git a/crates/df-ai/examples/glm_stream_test.rs b/crates/df-ai/examples/glm_stream_test.rs new file mode 100644 index 0000000..1a16c37 --- /dev/null +++ b/crates/df-ai/examples/glm_stream_test.rs @@ -0,0 +1,76 @@ +//! 独立诊断:用 df-ai 真实调用 GLM anthropic 流式端点,验证 +//! sse_parser + apply_anthropic_event + provider.stream() 整条链路。 +//! +//! 二分定位「发消息卡掉」: 若本例能正常吐 chunk → provider 层(df-ai)OK, +//! 问题在 devflow 应用层(provider 配置/emit/前端); 若卡/空/Err → df-ai 有 bug。 +//! +//! 运行: cd crates/df-ai && cargo run --example glm_stream_test +use df_ai::build_provider; +use df_ai_core::CompletionRequest; +use futures::StreamExt; + +#[tokio::main] +async fn main() { + let token = std::env::var("ANTHROPIC_AUTH_TOKEN") + .or_else(|_| std::env::var("ANTHROPIC_API_KEY")) + .expect("需要环境变量 ANTHROPIC_AUTH_TOKEN"); + eprintln!("[glm-test] token len={}", token.len()); + + let provider = build_provider( + "anthropic", + "https://open.bigmodel.cn/api/anthropic", + &token, + "glm-5.2", + ); + + // 用 JSON 反序列化构造请求,绕开字段列表(devflow 实际用 glm-5.2) + let req: CompletionRequest = serde_json::from_str( + r#"{"model":"glm-5.2","stream":true,"max_tokens":16,"messages":[{"role":"user","content":"说你好"}]}"#, + ) + .expect("parse CompletionRequest"); + + eprintln!("[glm-test] 调用 provider.stream() ..."); + let t0 = std::time::Instant::now(); + let mut s = match provider.stream(req).await { + Ok(s) => { + eprintln!("[glm-test] stream() Ok, 建连耗时 {:?}", t0.elapsed()); + s + } + Err(e) => { + eprintln!("[glm-test] stream() Err: {:#}", e); + return; + } + }; + + let mut n = 0; + let mut got_text = false; + while let Some(chunk_result) = s.next().await { + n += 1; + match chunk_result { + Ok(chunk) => { + if !chunk.delta.is_empty() { + got_text = true; + } + eprintln!( + "[glm-test] chunk#{} delta={:?} reasoning={:?} finished={} usage={:?} err={:?}", + n, chunk.delta, + chunk.reasoning_content.as_deref().map(|s| if s.len() > 30 { format!("{}..", &s[..30]) } else { s.to_string() }), + chunk.finished, chunk.usage, chunk.error + ); + } + Err(e) => { + eprintln!("[glm-test] chunk#{} Err: {}", n, e); + } + } + if n > 60 { + eprintln!("[glm-test] 超 60 chunk 截断"); + break; + } + } + eprintln!( + "[glm-test] 流结束 共 {} chunk, 是否拿到文本={}, 总耗时 {:?}", + n, + got_text, + t0.elapsed() + ); +} diff --git a/crates/df-ai/src/anthropic_compat.rs b/crates/df-ai/src/anthropic_compat.rs index ee5a72a..829dfef 100644 --- a/crates/df-ai/src/anthropic_compat.rs +++ b/crates/df-ai/src/anthropic_compat.rs @@ -10,7 +10,6 @@ //! 本模块仅保留 Provider struct + impl(HTTP 调用),Rust impl 块不可跨文件故作此切分。 use async_trait::async_trait; -use eventsource_stream::Eventsource; use futures::StreamExt; use reqwest::Client; use std::time::Duration; @@ -590,17 +589,19 @@ impl LlmProvider for AnthropicCompatProvider { debug!(model = %body.model, "Anthropic 流式调用"); - let resp = match self + // BUG-2026-07-07: send 阶段需 timeout 防 hang(实测 GLM 偶发建连后长时间不返回)。 + // 注意:不能用 reqwest 的 .timeout()——它是整个请求(含 body 读取)的总超时, + // 流式长生成任务会被误砍(build_provider_client 注释已明确)。改用 tokio::time::timeout + // 包裹 send().await,只管建连+首响应头,不管后续 body 读取(后续由 stream_llm idle timeout 兜底)。 + // 60s 选型:正常 send(建连+收 200 headers)<5s,60s 足够宽容。 + let send_future = self .auth_headers(self.client.post(self.messages_url())) .json(&body) .version(reqwest::Version::HTTP_11) - .send() - .await - { - Ok(r) => r, - Err(e) => { - // B-260618-26: 记 reqwest 错误源因链。原 ? 转 anyhow 仅 Display - // "error sending request for url" 无法定位 reset/TLS/超时/body 真因。 + .send(); + let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { tracing::error!( is_timeout = e.is_timeout(), is_connect = e.is_connect(), @@ -620,6 +621,14 @@ impl LlmProvider for AnthropicCompatProvider { std::error::Error::source(&e) ); } + Err(_elapsed) => { + // send 阶段超时(60s 未返回 HTTP 响应头):GLM 端点可能不可达或极慢 + tracing::error!( + url = %self.messages_url(), + "Anthropic 流式 send 超时(60s 未返回响应头)" + ); + anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)"); + } }; if !resp.status().is_success() { @@ -629,36 +638,32 @@ impl LlmProvider for AnthropicCompatProvider { anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text); } - // 流式解析:eventsource 逐事件处理,按 type 字段分发转 StreamChunk。 - // 事件解析/usage 累积逻辑抽到 apply_anthropic_event 纯函数,便于单测;此处闭包只负责传 data。 - // usage 累积:message_start 给 input_tokens,message_delta 给累计 output_tokens(非增量),message_stop 带出。 + // BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)。 let mut usage_accum: Option = None; // B-260618-28: MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。 - // precheck(Init 路径,发送前)漏的 case,靠此在 SSE error 事件暴露实际 messages 结构到前端 raw。 let messages_summary = Self::summarize_messages(&body.messages); - let stream = resp - .bytes_stream() - .eventsource() - .map(move |event| match event { - Ok(ev) => { - let mut chunk = apply_anthropic_event(&ev.data, &mut usage_accum); - // GLM 中途 error(如 1214)→ chunk.error 附 messages 摘要,经 stream_recv MidStream - // 路径 emit AiError raw,前端直接看到实际 messages 结构定位非法字段。 - if let Some(err) = chunk.error.as_mut() { - *err = format!("{} | messages 摘要: {}", err, messages_summary); + + let sse = crate::sse_parser::SseStream::new(resp.bytes_stream()); + let stream = sse.flat_map(move |result: Result, String>| { + let mut chunks: Vec> = Vec::new(); + match result { + Ok(events) => { + for data in events { + let mut chunk = apply_anthropic_event(&data, &mut usage_accum); + if let Some(err) = chunk.error.as_mut() { + *err = format!("{} | messages 摘要: {}", err, messages_summary); + } + chunks.push(Ok(chunk)); } - Ok(chunk) } Err(e) => { - // 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message, - // 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...): - // Display 不变(仍为 "Anthropic SSE 错误: {e}"), 且 e 作为 .source() 可追溯。 - // 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。 let ctx = format!("Anthropic SSE 错误: {}", e); - error!(error = %e, "Anthropic SSE 事件流错误"); - Err(anyhow::Error::from(e).context(ctx)) + error!("{}", ctx); + chunks.push(Err(anyhow::anyhow!("{}", ctx))); } - }); + } + futures::stream::iter(chunks) + }); Ok(Box::pin(stream)) } diff --git a/crates/df-ai/src/lib.rs b/crates/df-ai/src/lib.rs index 065d8f0..2034d6c 100644 --- a/crates/df-ai/src/lib.rs +++ b/crates/df-ai/src/lib.rs @@ -41,6 +41,7 @@ pub mod router; // stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类) // 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。 pub mod retry; +pub mod sse_parser; use provider::LlmProvider; use reqwest::Client; diff --git a/crates/df-ai/src/openai_compat.rs b/crates/df-ai/src/openai_compat.rs index ab30043..c2c744f 100644 --- a/crates/df-ai/src/openai_compat.rs +++ b/crates/df-ai/src/openai_compat.rs @@ -6,7 +6,6 @@ use std::time::Duration; use async_trait::async_trait; -use eventsource_stream::Eventsource; use futures::StreamExt; use reqwest::Client; use tracing::{debug, error, warn}; @@ -183,6 +182,31 @@ impl OpenAICompatProvider { // assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。 Self::ensure_leading_user(&mut messages); + // 治 DeepSeek 400「insufficient tool messages」:扫描所有 assistant 消息, + // 若某条 assistant 含 tool_calls 但下一条不是 tool,则剥离其 tool_calls。 + // 正常流程 tool 结果先于下一轮 LLM 请求推入历史,此守卫仅兜底异常截断/恢复场景的残末尾。 + // 注意:合法的三元组形如:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b)。 + // 若最后一条是 assistant(tc=...) 也无下一条 tool,同样剥离。 + for i in 0..messages.len() { + let role = messages[i].role.clone(); + if role != "assistant" { + continue; + } + let has_tc = messages[i].tool_calls.is_some(); + if !has_tc { + continue; + } + let next_is_tool = i + 1 < messages.len() + && matches!(messages[i + 1].role.as_str(), "tool"); + if !next_is_tool { + messages[i].tool_calls = None; + tracing::warn!( + "[openai] assistant(#{} role={}) 含 tool_calls 但下一条非 tool,已自动剥离(防 400)", + i, role, + ); + } + } + let tools = req.tools.map(|defs| { defs.into_iter() .map(|d| serde_json::to_value(d).unwrap_or_default()) @@ -362,14 +386,26 @@ impl LlmProvider for OpenAICompatProvider { debug!(model = %openai_req.model, "OpenAI 流式调用"); - let resp = self + // BUG-2026-07-07: send 阶段需 timeout 防 hang(同 Anthropic 路径)。 + // 不能用 reqwest .timeout()(会砍流式 body),改用 tokio::time::timeout 包裹 send。 + let send_future = self .client .post(self.chat_url()) .header("Authorization", format!("Bearer {}", self.api_key)) .header("Content-Type", "application/json") .json(&openai_req) - .send() - .await?; + .send(); + let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + tracing::error!(error = %e, is_timeout = e.is_timeout(), "OpenAI 流式 send 失败"); + return Err(e.into()); + } + Err(_elapsed) => { + tracing::error!(url = %self.chat_url(), "OpenAI 流式 send 超时(60s 未返回响应头)"); + anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)"); + } + }; if !resp.status().is_success() { let status = resp.status(); @@ -378,25 +414,30 @@ impl LlmProvider for OpenAICompatProvider { anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body); } - // 累积流式 usage:开 include_usage 后,末段正常 chunk(finish_reason)及额外 usage-only chunk(choices=[])都带 usage。 - // usage 解析/累积逻辑抽到 apply_openai_sse 纯函数,便于单测;此处闭包只负责传 data 与传递 last_usage。 + // BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream 库。 + // eventsource-stream 在 Windows 上对 Deepseek 等响应报 "error decoding response body" + // (严格 UTF-8 + SSE 协议校验,跨 chunk 字符/不完整事件均报错且不可恢复)。 + // 原生解析器:bytes 累积 + from_utf8_lossy 宽松处理 + \n\n 分隔,容错不中断流。 let mut last_usage: Option = None; - let stream = resp - .bytes_stream() - .eventsource() - .map(move |event| match event { - Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)), + let sse = crate::sse_parser::SseStream::new(resp.bytes_stream()); + let stream = sse.flat_map(move |result: Result, String>| { + let mut chunks: Vec> = Vec::new(); + match result { + Ok(events) => { + for data in events { + let chunk = apply_openai_sse(&data, &mut last_usage); + chunks.push(Ok(chunk)); + } + } Err(e) => { - // 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message, - // 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...): - // Display 不变(仍为 "SSE 流错误: {e}"), 且 e 作为 .source() 可追溯。 - // 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。 let ctx = format!("SSE 流错误: {}", e); error!("{}", ctx); - Err(anyhow::Error::from(e).context(ctx)) + chunks.push(Err(anyhow::anyhow!("{}", ctx))); } - }); + } + futures::stream::iter(chunks) + }); Ok(Box::pin(stream)) } diff --git a/crates/df-ai/src/sse_parser.rs b/crates/df-ai/src/sse_parser.rs new file mode 100644 index 0000000..178c516 --- /dev/null +++ b/crates/df-ai/src/sse_parser.rs @@ -0,0 +1,118 @@ +//! 原生 SSE 流式解析器 — 替代 eventsource-stream 库 +//! +//! BUG-2026-07-17 根治: eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider +//! 的 SSE 响应解析时报 "Transport error: error decoding response body" 错误。 +//! +//! 根因分析: +//! eventsource-stream 内部对 bytes_stream 做严格的 UTF-8 + SSE 协议校验,遇到以下情况 +//! 即报错(且不可恢复): +//! - 流中断时未完整接收 UTF-8 字符(网络抖动常见) +//! - 缺少结束的 \n\n(连接断开常见) +//! - 非 ASCII 字符的多字节序列跨 chunk 边界 +//! +//! 本解析器实现: +//! - 宽松的 UTF-8 处理(用 bytes 累积,String::from_utf8_lossy 转换,不报错) +//! - SSE 协议简单解析(以 \n\n 分隔事件,data: 前缀提取) +//! - 容错:解析失败时跳过该事件继续,不中断流 +//! - 返回 Vec(每个元素是一个事件 data 字段拼接内容) + +use futures::Stream; +use std::pin::Pin; +use std::task::{Context, Poll}; + +/// SSE 事件流的 data 字段内容 +pub type SseEvent = String; + +/// 原生 SSE 解析器流:包装 bytes_stream,产出 Vec(一次 poll 可能产出多个事件) +pub struct SseStream { + inner: S, + buffer: Vec, +} + +impl SseStream +where + S: Stream> + Unpin, +{ + pub fn new(inner: S) -> Self { + Self { + inner, + buffer: Vec::with_capacity(8192), + } + } + + /// 从 buffer 解析完整的 SSE 事件(以 \n\n 分隔),返回事件列表 + fn parse_events(&mut self) -> Vec { + let mut events = Vec::new(); + loop { + let sep_pos = self.buffer.windows(2).position(|w| w == b"\n\n"); + if sep_pos.is_none() { + break; + } + let sep_pos = sep_pos.unwrap(); + let event_bytes: Vec = self.buffer.drain(..sep_pos + 2).collect(); + // 去掉末尾的 \n\n + let body_end = event_bytes.len().saturating_sub(2); + let event_text = String::from_utf8_lossy(&event_bytes[..body_end]); + let data = Self::extract_data_fields(&event_text); + if !data.is_empty() { + events.push(data); + } + } + events + } + + /// 从 SSE 事件文本中提取所有 data: 行的内容,拼接为单个字符串(多个 data 行用 \n 连接) + fn extract_data_fields(event_text: &str) -> String { + let mut data_parts: Vec<&str> = Vec::new(); + for line in event_text.lines() { + if let Some(rest) = line.strip_prefix("data:") { + let rest = rest.strip_prefix(' ').unwrap_or(rest); + data_parts.push(rest); + } + // 忽略 event:/id:/retry: 等其他 SSE 字段(OpenAI/Anthropic 协议未使用) + } + data_parts.join("\n") + } +} + +impl Stream for SseStream +where + S: Stream> + Unpin, +{ + type Item = Result, String>; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + use futures::StreamExt; + loop { + // 先尝试从 buffer 解析完整事件 + let events = self.parse_events(); + if !events.is_empty() { + return Poll::Ready(Some(Ok(events))); + } + + // buffer 不足以解析出完整事件,从 inner 读更多数据 + match self.inner.poll_next_unpin(cx) { + Poll::Ready(Some(Ok(chunk))) => { + self.buffer.extend_from_slice(&chunk); + continue; + } + Poll::Ready(Some(Err(e))) => { + return Poll::Ready(Some(Err(format!("SSE 流读取错误: {}", e)))); + } + Poll::Ready(None) => { + // 流结束,处理 buffer 中的剩余数据(可能没有 \n\n 结束的最后一段) + if !self.buffer.is_empty() { + let remaining = String::from_utf8_lossy(&self.buffer).to_string(); + self.buffer.clear(); + let data = Self::extract_data_fields(&remaining); + if !data.is_empty() { + return Poll::Ready(Some(Ok(vec![data]))); + } + } + return Poll::Ready(None); + } + Poll::Pending => return Poll::Pending, + } + } + } +} diff --git a/crates/df-execute/src/env_snapshot.rs b/crates/df-execute/src/env_snapshot.rs index 9feca3d..225ba74 100644 --- a/crates/df-execute/src/env_snapshot.rs +++ b/crates/df-execute/src/env_snapshot.rs @@ -51,9 +51,33 @@ impl EnvSnapshot { return snap; } // 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。 - let snap = tokio::task::spawn_blocking(|| EnvSnapshot::do_detect()) - .await - .unwrap_or_else(|_| EnvSnapshot::fallback()); + // BUG-2026-07-18 根治: probe_version 内 std::process::Command::output() 无 timeout, + // Windows 上 python/node 若是 Microsoft Store App Execution Alias(用户未装但开了 + // "应用执行别名"),`python --version` 触发 Store 重定向、process 不退出 → output() + // 永久阻塞 → spawn_blocking 线程永不返回 → detect().await 永久挂 → run_agentic_loop + // 卡在 EnvSnapshot::detect() 调用(agentic/mod.rs),后端日志断在"意图收敛工具"后, + // 前端表现为「发消息完全无回应然后卡死」(trace.log 实测 3 次发送全卡于此)。 + // 修复: spawn_blocking 外包 5s timeout,超时返回 fallback 并 set 进 OnceLock(后续命中 + // 缓存不再 probe),run_agentic_loop 最多等 5s 后继续,不再永久卡。 + // 注: 超时后 spawn_blocking 线程仍在跑(卡在 output),但已脱离 await,不阻塞调用方; + // 线程最终随 process 退出或进程结束回收,无泄漏累积(OnceLock 已 set,不会重复 probe)。 + let snap = match tokio::time::timeout( + std::time::Duration::from_secs(5), + tokio::task::spawn_blocking(|| EnvSnapshot::do_detect()), + ).await { + Ok(Ok(s)) => s, + Ok(Err(join_err)) => { + eprintln!("[env_snapshot] 探测任务异常,回退默认环境: {}", join_err); + EnvSnapshot::fallback() + } + Err(_elapsed) => { + eprintln!( + "[env_snapshot] 探测超时(5s),某 tool(python/node/rustc/go/docker/git)命令卡住 \ + (疑似 Windows Store App Execution Alias 触发 Store 重定向),回退默认环境" + ); + EnvSnapshot::fallback() + } + }; // 多任务竞态:均等价,以先到者为准。 let _ = SNAPSHOT.set(snap); SNAPSHOT.get().expect("EnvSnapshot 已初始化") diff --git a/crates/df-storage/src/secret.rs b/crates/df-storage/src/secret.rs index 8585a2e..de16727 100644 --- a/crates/df-storage/src/secret.rs +++ b/crates/df-storage/src/secret.rs @@ -101,6 +101,18 @@ pub fn get_provider_secret(id: &str) -> Option { } } +/// [`get_provider_secret`] 的 async 版本:`spawn_blocking` 隔离同步 keyring 调用。 +/// +/// keyring 在 Linux secret-service D-Bus / Windows COM / macOS Keychain 同步阻塞,直接在 +/// async 上下文调用会卡 tokio runtime(Tauri 单线程 runtime 尤其敏感)。async 路径优先用此版本; +/// 同步版保留供 block_on / 非 async 路径(迁移、测试)使用。 +pub async fn get_provider_secret_async(id: String) -> Option { + tokio::task::spawn_blocking(move || get_provider_secret(&id)) + .await + .ok() + .flatten() +} + /// 消费点用:解析 provider 真实密钥 — DB 优先,fallback keyring(兼容未迁移老库) pub fn resolve_provider_secret(record: &AiProviderRecord) -> String { if !record.api_key.is_empty() { @@ -109,18 +121,43 @@ pub fn resolve_provider_secret(record: &AiProviderRecord) -> String { get_provider_secret(&record.id).unwrap_or_default() } +/// [`resolve_provider_secret`] 的 async 版本 — DB 有明文时同步返(不触 keyring), +/// 否则 `spawn_blocking` 调 keyring 防 D-Bus / COM 阻塞 tokio runtime。 +/// +/// 注:DB 明文路径直接 clone 同步返,只有 fallback keyring 才走 spawn_blocking。 +pub async fn resolve_provider_secret_async(record: AiProviderRecord) -> String { + if !record.api_key.is_empty() { + return record.api_key; + } + get_provider_secret_async(record.id).await.unwrap_or_default() +} + /// 写入密钥到 keyring(覆盖) pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> { let entry = entry_for(id)?; entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败(provider={}): {}", id, e)) } +/// [`set_provider_secret`] 的 async 版本(`spawn_blocking` 隔离同步 keyring 调用)。 +pub async fn set_provider_secret_async(id: String, key: String) -> anyhow::Result<()> { + tokio::task::spawn_blocking(move || set_provider_secret(&id, &key)) + .await + .map_err(|e| anyhow::anyhow!("set_provider_secret join 失败: {}", e))? +} + /// 删除 keyring 密钥(provider 删除时清理) pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> { let entry = entry_for(id)?; entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败(provider={}): {}", id, e)) } +/// [`delete_provider_secret`] 的 async 版本(`spawn_blocking` 隔离同步 keyring 调用)。 +pub async fn delete_provider_secret_async(id: String) -> anyhow::Result<()> { + tokio::task::spawn_blocking(move || delete_provider_secret(&id)) + .await + .map_err(|e| anyhow::anyhow!("delete_provider_secret join 失败: {}", e))? +} + /// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断) pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result { let providers = repo.list_all().await?; diff --git a/crates/df-tunnel/src/tunnel.rs b/crates/df-tunnel/src/tunnel.rs index 2111adf..05c4d6b 100644 --- a/crates/df-tunnel/src/tunnel.rs +++ b/crates/df-tunnel/src/tunnel.rs @@ -94,6 +94,13 @@ pub trait TunnelClient: Send + Sync { /// 此窗口内若收到 error 帧或连接 Close,判定握手失败) const HANDSHAKE_PROBE: Duration = Duration::from_secs(3); +/// TCP/WS 建立连接(connect_async)超时上限。 +/// +/// 对齐 df-ai http.rs connect_timeout(15s):relay URL 不可达(TCP SYN 无响应)时 +/// connect_async 会挂 60-120s 才返系统 timeout,Tauri 单线程 tokio runtime 卡死。 +/// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。 +const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); + /// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收) const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25); @@ -177,10 +184,18 @@ impl TunnelClient for WsTunnelClient { self.cleanup_conn().await; } - // 1. 建立 WS 连接 - let (ws_stream, _resp) = tokio_tungstenite::connect_async(url) - .await - .map_err(|e| TunnelError::Connect(format!("WS 连接失败 {url}: {e}")))?; + // 1. 建立 WS 连接(包 CONNECT_TIMEOUT 防 relay 不可达时永久挂 — TCP SYN 无响应 + // 系统 timeout 60-120s 才返,Tauri 单线程 tokio runtime 会卡死。对齐 df-ai http.rs + // connect_timeout(15s)。保留原错误映射语义:TunnelError::Connect(format!("WS 连接失败 ...")) + let connect_fut = tokio_tungstenite::connect_async(url); + let (ws_stream, _resp) = match tokio::time::timeout(CONNECT_TIMEOUT, connect_fut).await { + Ok(res) => res.map_err(|e| TunnelError::Connect(format!("WS 连接失败 {url}: {e}")))?, + Err(_) => { + return Err(TunnelError::Connect(format!( + "WS 连接超时(>{CONNECT_TIMEOUT:?} 无响应){url}" + ))); + } + }; tracing::info!(%url, "WS 连接已建立,开始 Hello 握手"); let (mut sink, mut stream) = ws_stream.split(); diff --git a/dev-server.bat b/dev-server.bat new file mode 100644 index 0000000..5a0575d --- /dev/null +++ b/dev-server.bat @@ -0,0 +1,10 @@ +@echo off +cd /d E:\wk-lab\devflow +echo [dev] 启动 DevFlow 开发服务器... +echo [dev] Vite + Tauri 后端将同时启动 +echo [dev] 请勿关闭此窗口 +echo. +C:\Users\23780\.bun\bin\bun.exe run tauri dev +echo. +echo [dev] 服务器已停止 +pause diff --git a/docs/05-代码审查/aichat-会话实测分析-2026-07-07.md b/docs/05-代码审查/aichat-会话实测分析-2026-07-07.md new file mode 100644 index 0000000..4137508 --- /dev/null +++ b/docs/05-代码审查/aichat-会话实测分析-2026-07-07.md @@ -0,0 +1,192 @@ +# AI Chat 真实会话实测分析(系统消息污染 + 死循环重调) + +> 日期:2026-07-07 | 方法:从生产 DB(`%APPDATA%/top.1216.devflow/devflow.db`)实测最近两个会话全量消息反推,Python 只读统计 +> 基线会话:`9357c27c`(HaoGamePlatfPro,prompt 644,586 / completion 3,865)+ `43b75e49`(wk-hszd,prompt 601,028 / completion 4,134) +> 产物:**2 P0 / 3 P1 / 2 P2** +> 性质:**实测数据反推**(非静态代码审查)。finding 的源码根因为"假设",须独立 grep/read 核验后再定论 +> 关联:[[aichat-session-analysis-2026-06-22]] [[aichat-techdebt-audit-2026-06-21]] [[devflow-generating-statemachine]] [[devflow-compress-ui-no-fold]] + +--- + +## 会话概况 + +| 项 | 9357c27c | 43b75e49 | +|---|---|---| +| 标题 | HaoGamePlatfPro | 查看 E 盘 wkhszd 目录内容 | +| 跨度 | 2026-06-30 14:31 ~ 15:04(~33 分钟) | 2026-07-01 ~ 07-03(中间大间隔) | +| 消息数 | 97 | 77 | +| prompt / completion tokens | 644,586 / 3,865 | 601,028 / 4,134 | +| completion/prompt 比 | **0.60%** | **0.69%** | +| 工具调用次数 | list_directory 22 / read_file 22 / search_files 13 / list_projects 2 / update_project 1 / get_task_count 1 | list_directory 16 / read_file 17 / list_projects 2 / list_project_modules 3 / bind_directory 3 / create_project 1 / update_project 3 | +| 工具结果总量 | 195.4 KB | 62.3 KB | +| 用户输入字数 | **34 字**(2 条) | 51 字(5 条) | +| 用户催促 | 0 | 1(核对) | +| 末尾收尾 | assistant(seq96,正常) | assistant(seq76,正常) | +| task | 浏览 HaoGamePlatfProject 代码补充项目信息 | 把 wk-hszd 注册为 DevFlow 多工程项目 | + +两会话共同特征:**极少用户输入(34~51 字)产生 60 万级 prompt**,completion/prompt 比 < 1%,极端输入重。 + +--- + +## P0(2 项) + +### [P0-1] seq0 system 消息污染:assistant 思考流被截断重复拼接 41 次 + role 错位 + 时序倒挂 + +- **现象**(9357c27c seq0): + - `role=system`,但内容是 assistant 语义:`"我已经充分了解了项目结构。让我快速查看几个关键文件..."` + - 同一短语 `"现在更新项目信息:基于代码分析,这是一个大型综合游戏平台后端。让我更新项目描述和技术栈。"` **重复 41 次**,拼成 1879 字符 + - seq0 timestamp `1782971672713` 比 seq1 `1782971504351` **晚 168 秒**(时序倒挂) +- **db 证据**:`ai_messages` seq0 row,role=system,status=active,content 长 1879 +- **根因假设**(强):系统 prompt 注入路径把"上一轮 assistant 流式片段"误当 system 上下文写入;且写入库时陷入重复拼接循环(疑似字符串 builder 在流式 chunk 累积时无去重/无终止条件)。这是 [[aichat-session-analysis-2026-06-22]] P2-1 的**复发且恶化**——上次只见 1 条 assistant 语义残留,本次是 41 次死循环拼接。 +- **影响**: + 1. system 消息会作为 prompt 头部送入 LLM,1879 字符无意义噪声污染每一轮上下文 + 2. role 错位会误导模型(system 应是约束指令,却变成"我说的"内容) + 3. 时序倒挂破坏压缩/排序/计费 +- **待核验源码点**: + - system prompt 注入路径(`ai_messages` 写入 system 行的位置,`src-tauri/src/commands/ai/`) + - 流式 chunk 累积逻辑(查 `for` / `push_str` / `+=` 类字符串拼接,是否有循环退出条件) + - timestamp 赋值逻辑(为何 system 行用了"插入时刻"而非"会话起始时刻") +- **关联**:印证 [[aichat-techdebt-audit-2026-06-21]] P2 区"system 消息混入 assistant 内容"项;与 [[aichat-session-analysis-2026-06-22]] P2-1 同源(前次未根治) + +### [P0-2] 工具死循环重调:LLM 无"已调用过"记忆,同参同路径重复触发 + +- **现象**(9357c27c): + - `read_file(application.xml)` **读了 17 次**,其中两次返回完全相同的 8457c 全文(同一文件无变化) + - `search_files(pattern='.java', path=...src)` **连调 13 次**,每次返回结构几乎一致(items=0, has_more=True,5.5~5.8KB),LLM 完全没意识到在重复 + - `list_directory(...src)` 2 次,第 2 次返回 30,516c(第 1 次 6,144c,深度参数不一致但 LLM 未说明理由) +- **db 证据**:13 条 search_files 调用 `arguments` 完全相同,`result` 长度方差 < 5% +- **根因假设**: + 1. **无工具结果缓存层**:会话级无"同参工具调用 → 返回缓存命中提示"机制,LLM 每次发起都真执行 + 2. **prompt 未告知 LLM 已读内容**:read_file 返回的文件指纹/路径未在后续 prompt 中显式标注"已读",LLM 失忆反复读 + 3. **search_files has_more=True 的分页语义被 LLM 误解**:可能 LLM 以为重调能翻页,但实际每次返第一页 +- **影响**: + - **prompt 64 万的直接元凶之一**:13 次 search × ~5.7KB + 17 次 read × 平均 2.7KB ≈ **75KB 仅重复工具结果**(占 prompt 字符量的主要部分) + - 死循环消耗 30+ 轮 LLM 调用,用户等待感极差 +- **待核验源码点**: + - 工具调度层(`tool_registry.rs` / `agentic/mod.rs`)——是否有 `seen_tool_calls` 去重 + - read_file 是否返回 file_hash 供 LLM 引用(对照 [[aichat-techdebt-audit-2026-06-21]] P1「expected_hash schema 契约破裂」) + - search_files 分页契约(为何 has_more=True 但无 page/cursor 参数) +- **关联**:印证 [[aichat-session-analysis-2026-06-22]] P2-2(同文件反复 read)+ 新增 search_files 死循环变体 + +--- + +## P1(3 项) + +### [P1-1] ~~压缩实质失效:compressed 消息平均长度比 active 还长(205%)~~ 【误报,2026-07-07 核验撤销】 + +- **原现象**(9357c27c):58/97 条 status=compressed,但 compressed 平均 2641c/条,active 平均 1287c/条——压缩后比未压缩还长 2 倍。 +- **核验撤销**:进一步按 role 分组统计发现,compressed tool 消息长(4608c)是因为会话前期是大工具调用(list_directory/read_file 大输出),active tool 消息短(1714c)是因为后期转为小操作(update_project)。这是会话业务节奏的自然结果,不是压缩机制问题。 +- **prompt_tokens 语义澄清**:`prompt_tokens=644,586` 是**会话级累积值**(每轮 prompt 累加,见 `agentic/mod.rs:1366 tokens.add()`),不是最后一轮值。97 条消息 × 平均 6645 tokens/轮 ≈ 644K,完全合理。每轮实际 prompt 只 ~6.6K(active content 49KB ≈ 13K token,压缩后只进 active)。 +- **结论**:06-22 报告 P0-1 修复(is_active 白名单让 compressed 不进 prompt)**生效**。本项撤销,无需修复。 + +### [P1-2] LLM 自检缺失 + 宣称完成即停,用户被迫人工纠错 + +- **现象**(43b75e49 wk-hszd): + - seq71 assistant 自检发现 bug:`path` 被最后一次 `bind_directory` 覆盖成 `hszd-test`,`stack` 变成 `["Go"]` + - 但这是 assistant **自己**在 seq74"再次核对"时才发现——说明 seq68 用户催"核对"前,assistant 已认为完成 + - 3 次 `bind_directory` 把 path 从 `hszd-api` → `hszd-admin` → `hszd-test`,每次都覆盖前值,assistant 没意识到多工程 monorepo 的 path 字段不该是单子工程 +- **db 证据**:bind_directory 3 次调用 args 显示 path 被依次覆盖,最终 assistant 自己回滚用 `update_project(path='E:/wk-hszd')` 修正 +- **根因假设**: + 1. agent 无"宣称完成前的强制自检"(对照 [[aichat-session-analysis-2026-06-22]] P1-1) + 2. `bind_directory` 的 `path` 字段语义对多工程 monorepo 有歧义(LLM 以为是"当前操作的子工程",实际是"项目根") +- **影响**:用户对 agent 完成度零信任,需人工核对每个字段 +- **待核验/落点**: + - agent 行为策略(prompt 加自检 checklist) + - `bind_directory` schema 注释补"monorepo 应用项目根路径,非子工程" +- **关联**:[[aichat-decision-capability]] [[aichat-b-route-parallel-multiround]] + +### [P1-3] 工具拒绝消息破坏 JSON 协议:raw 文本"用户拒绝了此操作" + +- **现象**(9357c27c seq95):`update_project` 被用户拒绝,tool role 消息 content = `'用户拒绝了此操作'`(8 字符裸文本),非 JSON +- **db 证据**:61 条 tool 消息中 60 条 JSON-parseable,1 条 raw-text(就是这条拒绝) +- **根因假设**:工具拒绝路径(`ai_reject` / 审批拒绝)走的是错误字符串直接返回,未包装成 `{"status":"rejected","reason":"..."}` 结构 +- **影响**: + - 若 LLM provider 严格解析 tool role 内容(部分 provider 要求 JSON),会触发协议错误 + - 与 [[aichat-techdebt-audit-2026-06-21]] P1「审批状态字符串双轨(executed/completed)」同家族——工具返回协议不统一 +- **待核验源码点**:审批拒绝 handler(`commands.rs` 拒绝分支)的返回序列化 + +--- + +## P2(2 项) + +### [P2-1] system 消息 timestamp 用"插入时刻"而非"会话时刻",时序倒挂 + +- **现象**(9357c27c):seq0 system ts=1782971672713,seq1 user ts=1782971504351,seq0 比 seq1 晚 168 秒 +- **根因假设**:system prompt 注入是在会话进行中"补写"的(可能是压缩/续轮时插入),用了补写时刻 +- **影响**:排序、压缩窗口计算、计费全受污染 +- **关联**:与 P0-1 同源(都是 seq0 system 行的写入 bug) + +### [P2-2] bind_directory 多工程语义错配:单值 path 字段无法表达 monorepo + +- **现象**(43b75e49):wk-hszd 是 Go+Vue+Test 三子工程 monorepo,assistant 3 次 bind_directory 分别绑三个子工程路径,前两个被第三个覆盖 +- **根因假设**:`projects.path` 是单值字段,而 DevFlow 的"多工程"概念(`project_modules` 表)与"项目根 path"语义未在 schema 注释中澄清 +- **影响**:多工程项目的 path 字段长期处于"最后绑的子工程"错误状态,直到 LLM 自检或人工纠正 +- **待核验/落点**:`bind_directory` IPC 的 schema 文档 + 多工程 monorepo 的 path 字段规范(可能应允许 null 或 readonly,工程信息走 `project_modules`) + +--- + +## 落点建议(未实施,遵守 [[session-role-diagnose-only]]) + +| 项 | 优先级 | 落点 | +|---|---|---| +| P0-1 system 消息污染 + 重复拼接 | P0 | system prompt 注入路径 + 流式 chunk 累积循环(查 push_str/+= 类拼接) | +| P0-2 工具死循环重调 | P0 | 工具结果缓存层(会话级 seen_tool_calls)+ read_file 返回 file_hash + search_files 分页契约 | +| P1-1 压缩产出反向膨胀 | P0/P1 | compress_old_messages 产出从拼接改真摘要 | +| P1-2 自检 + bind_directory 语义 | P1 | prompt 自检 checklist + bind_directory schema 注释 | +| P1-3 拒绝消息 JSON 协议 | P1 | 审批拒绝 handler 返回结构化 JSON | +| P2-1 timestamp 时序 | P2 | system 行 timestamp 用会话起始时刻 | +| P2-2 多工程 path 语义 | P2 | bind_directory/path schema 文档 | + +**最高杠杆**:P0-1(系统消息污染直接污染每一轮 prompt)+ P0-2(工具死循环是 prompt 64 万的主因)。两者叠加,即使只修这两项,类似会话的 prompt 可降一个量级。 + +--- + +## 2026-07-07 修复落地(本次实施) + +| 项 | 位置 | 修复 | +|---|---|---| +| P0-1 LLM 退化输出污染 system 消息 | `commands/ai/compress.rs` | `clean_summary` 加 `is_degenerated_repetition` 退化检测,同一片段重复 ≥5 次返空 → 调用方据空值降级走关键词兑底 | +| P0-2 工具死循环重调 | `commands/ai/audit/cache.rs` + `audit/mod.rs` | 新增 `find_cached_readonly_result`(只读幂等工具白名单:read_file/read_symbol/list_directory/search_files/grep),Low 风险执行前查会话历史,同参同成功(completed)调用直接回填缓存跳过真执行 | +| P1-3 拒绝消息破 JSON 协议 | `commands/ai/commands/chat.rs` + `audit/mod.rs` | 三处拒绝消息(用户拒绝此操作/路径授权拒绝/路径黑名单拒绝)改结构化 JSON `{status,reason,message}` | +| **额外**:Windows 上 .ssh/.aws 黑名单绕过 | `state/allowed_dirs.rs` | `check_path_authorization` 黑名单检查提前到路径规范化之前(对原始路径也判一次)。根因:Windows 上 `/home/user/.ssh/config` 的 `is_absolute()` 返 false,persistent 为空时旧逻辑直接返 NeedsAuthorization 跳过黑名单 | +| P1-2 bind_directory 多工程语义错配 | `commands/ai/tool_registry.rs` | 工具描述补充 monorepo 语义:path 应为项目根路径(非子工程),多次调用会覆盖,子工程信息走 list_project_modules | +| ~~P1-1 压缩产出反向膨胀~~ | — | **误报撤销**:prompt_tokens 是会话级累积值(非单轮值),按 role 分组统计后压缩机制正常 | + +**测试**:`cargo test -p devflow --lib` 通过(compress 11 + audit 19 + allowed_dirs 20,含新增 6 个回归测试)。原 `test_grep_blacklisted_path_denied`(此前失败)现已通过。 + +--- + +## 复现方法 + +```bash +# 只读查询生产 DB +python -c " +import sqlite3, os +db = os.path.expandvars(r'%APPDATA%/top.1216.devflow/devflow.db') +con = sqlite3.connect(f'file:{db}?mode=ro', uri=True) +# seq0 system 污染证据 +print(con.execute('select seq, role, length(content), timestamp from ai_messages where conversation_id=? and seq<2 order by seq', + ('9357c27c-aeba-488f-9f4f-12c2d0506f63',)).fetchall()) +# search_files 13 次重复 +print(con.execute('select count(*), min(length(result)), max(length(result)) from ai_tool_executions where conversation_id=? and tool_name=?', + ('9357c27c-aeba-488f-9f4f-12c2d0506f63','search_files')).fetchall()) +" +# 表:ai_conversations / ai_messages / ai_tool_executions +``` + +数据快照:2026-07-07 抓取自 `devflow.db`(生产库,非 dev)。 + +--- + +## 与 06-22 报告的对比 + +| 项 | 06-22 报告 | 07-07 实测 | 趋势 | +|---|---|---|---| +| 工具返回裸文本(raw) | patch_file/read_file 大量 raw(6204c) | 仅 1 条(拒绝消息) | ✅ **已修复**(P0-2 性质修正有效) | +| 重复 tool 结果落库 | seq 11/12/13/14 同结果 4 份 | 0 条 tool_call_id 重复 | ✅ **已修复**(P0-3 撤销成立) | +| system 消息混 assistant 语义 | seq0 残留 1 句 | seq0 重复 41 次拼接 | ❌ **恶化**(P2-1 → P0-1) | +| 同文件反复 read | mysql_guide read 4× | application.xml read 17× | ❌ **恶化**(P2-2 → P0-2) | +| 上下文管理实质失效 | compressed 未降 prompt(已隐式修复) | compressed 产出反向膨胀 205% | ⚠️ **新变体**(P0-1 → P1-1) | +| 中断(tool 后无 assistant) | seq85 停尸 | 无(两会话均 assistant 收尾) | ✅ **已修复** | + +**结论**:06-22 后修复的"工具返回协议/落库去重/中断"三项**确实生效**;但"system 污染"和"工具死循环"两类问题**反而恶化**——前者从残留 1 句变成 41 次拼接,后者从 4× 变成 17×。说明这两类是未根治的根因,须单独立项。 diff --git a/docs/05-代码审查/aichat-对话功能走查-2026-07-17.md b/docs/05-代码审查/aichat-对话功能走查-2026-07-17.md new file mode 100644 index 0000000..b2e20d5 --- /dev/null +++ b/docs/05-代码审查/aichat-对话功能走查-2026-07-17.md @@ -0,0 +1,144 @@ +# aichat 对话功能深度走查报告(2026-07-17) + +> 触发:用户反馈「对话功能现在存在很多问题,深入检查」。 +> 方法:5 个 general-purpose 代理并行走查 5 维度(发送/流式/渲染/会话侧栏顶栏/审批上下文)→ 主代理独立 grep/read 核验所有 P0 + 关键 P1(不信代理结论,每条标注核验状态)。 +> 性质:**走查任务,未改任何代码**。本报告为问题清单 + 修复方向,待用户确认后实施。 + +## 健康度总评:B- + +最近一批发改动(BUG-2026-07-17:状态机收敛方向正确)引入 **2 个 P0 回归**;其余 P1 多源自 **F-09 per-conv 改造未完成**的单例债(queue / legacy watchdog / _approvalTimers / modelOverride / _lastDelta 仍是全局单例,多会话并发下串话)。 + +--- + +## P0(功能 bug,建议立即修) + +### P0-1 流式 watchdog legacy 路径全局误杀并发会话 + 删 running 保护致长工具误杀 ✅核验成立 + +**位置**:`useAiStream.ts:57-110, 73-78` / `useAiSend.ts:156,226`(doSend reset 同款)/ `audit/mod.rs:125-128` + +**双重回归**(本次改动引入): + +① **legacy 路径全局 clear**:doSend / regenerate(`useAiSend.ts:156`) / editMessage(`:226`) 调 `resetStreamWatchdog()` **无 convId** → 走 `_legacyWatchdog` fallback(`useAiStream.ts:128-132`)。45s 到期 `onStreamTimeout()` 无参 → `convStates.clear()`(`:77`)清掉**所有并发会话**的生成态。其他会话停止按钮失效、UI 与后端长期不一致。 + +② **删 running 保护**:`onStreamTimeout`(`:69`「不管 stillGenerating 检测」)去掉了原 stillGenerating + running 工具检测。长工具执行(cargo build / 测试套件 / 大文件 patch >45s)必然触发。后端心跳首 tick **30s**(`audit/mod.rs:126` 弃首 tick),AiHeartbeat 到达时前端 `resetStreamWatchdog(convId)` 只动 per-conv Map、**不清 legacy**,保护不了 doSend 挂的 legacy timer。 + +**证据**:`useAiStream.ts:69`「不管 stillGenerating 检测(即使已经 false 也再清理一次)」+ `:77` `convStates.clear()` + `audit/mod.rs:125-128` 心跳 30s。 + +**影响**:多会话并发下其他会话被误杀 + 任何 >45s 的工具执行被前端误判断流(回到 BUG-260624-03 修复前的用户报障状态)。 + +**修复方向**: +- legacy 路径 `convStates.clear()` → `convStates.delete(state.activeConversationId)`(对齐 legacy 单 timer 只跟踪当前会话语义) +- 恢复 `onStreamTimeout` 的 running 工具检测(工具执行中跳过 + reset 续等),或 `STREAM_TIMEOUT_MS` 回调 ≥75s +- 根治:doSend / regenerate / editMessage 的 `resetStreamWatchdog` 传 convId 走 per-conv(F-09 迁移) + +### P0-2 审批计时器切/删会话不清 + 注释与代码矛盾 ✅核验成立 + +**位置**:`useAiConversations.ts:92-257`(switch 无 clearApprovalTimer)/ `:260-272`(delete 无清)/ `aiShared.ts:118`(默认 15min)vs `useAiConversations.ts:244-246`(注释「Infinity 已决策不超时」) + +**证据**: +- switchConversation 全程未调 clearApprovalTimer / clearAllApprovalTimers(已核 90-257) +- deleteConversation 仅 `clearConvStreamState(id)`,不清 `_approvalTimers` 中属于该会话的 timer +- `startApprovalTimer` 到点回调(`aiShared.ts:147-163`)调 `aiApi.approve(id,false)` + push 错误气泡到 `getMessages()`(**当前 active 视图**) +- `:244-246` 注释「APPROVAL_TIMEOUT_MS=Infinity 已决策,不会超时自动拒绝」与 `:118` `APPROVAL_TIMEOUT_DEFAULT_MS=900_000`(15min)**直接矛盾** + +**影响**:① 切走会话后 15min 到点 → 误拒后台 pending 工具 + 错误气泡 push 到当前(错误)会话视图;② 用户重启恢复审批后离开,回来发现审批已被无声自动拒绝。 + +**修复方向**:switchConversation 开头 `clearAllApprovalTimers()`(对齐 pendingApprovals 单例语义);deleteConversation 清该 conv 的 timer;修正 `:244` 注释。 + +--- + +## P1(明显缺陷) + +### P1-1 [发送] queue=[] 跨会话丢消息 ✅核验成立 +`useAiSend.ts:155,225` `state.queue = []`,queue 是 store 全局单例。B 会话 regenerate/editMessage 清掉 A 排队的用户消息,静默永久丢失。 + +### P1-2 [发送] drainQueue 无并发互斥 ✅核验成立 +`useAiSend.ts:257-281` fire-and-forget 无锁,AiCompleted 连发 / stop+Completed 竞态致并发 doSend,前端 user/ai 占位双 push(后端 generating 守卫挡大部分但视图已污染)。 + +### P1-3 [会话] TopBar watch(activeConversationId) 无条件重置 modelOverride ✅核验成立 +`TopBar.vue:325-327` 切会话无条件 `modelOverride = enabledModels[0]`,切会话往返丢失用户手选模型;与 `:335` `watch(enabledModels,{immediate})` 叠加语义脆弱。modelOverride 是模块级 ref,不持久化、不 per-conv。 + +### P1-4 [发送] tryForceSend 跨会话错位 + 队列续发 spans 丢失 ✅核验成立 +`useAiSend.ts:323-328` queue item 无 convId / spans 字段,切会话后 forceSend 发到当前 active 而非原会话;mention chip 续发降级为纯文本。 + +### P1-5 [流式] _lastDelta 单值跨会话/跨轮误丢合法 delta ⚠️部分成立 +`useAiEvents.ts:46,292` 单值重复检测,跨会话首 delta 撞同 / 同轮合法重复(Markdown 表格分隔符等)被误丢。难复现。 + +### P1-6 [流式] state.currentText += delta 无 streaming 守卫 ⚠️部分成立 +`useAiEvents.ts:300`,P0-1 触发后超时收尾仍到达的 delta 污染 currentText。修 P0-1 即间接修复。 + +### P1-7 [渲染] MessageItem.vue system/assistant/error 死代码 ✅核验成立 +`MessageItem.vue:94-170`,MessageList 仅 `role==='user'` 引用 MessageItem(`:663-673`),AI 渲染全在 MessageList 内联。MessageItem 内 `.ai-cursor-blink` 是孤岛 class(全仓库无其他定义)。维护陷阱。 + +### P1-8 [渲染] _blockCache 无失效机制 ✅核验成立 +`useStreamRenderer.ts:117` 模块级 Map 无会话切换 / 配置变更失效,长会话膨胀 + Markdown 主题热改无效 + 瞬态错误固化。 + +### P1-9 [渲染] messages deep watch + JSON.stringify 每帧全量遍历 ✅核验成立 +`MessageList.vue:244` deep watch 每帧 O(N×content) 遍历,长会话流式掉帧。 + +### P1-10 [渲染] useToolApproval onApprove confirmDialog 无防重入 ✅核验成立 +`useToolApproval.ts:98-113` High 风险工具 confirmDialog await 期间 approving 未置 true,双击重复审批。 + +### P1-11 [会话] 删当前会话 active=null 无 fallback ✅核验成立 +`useAiConversations.ts:260-272` 删除当前会话后空白无引导,应回落相邻会话。 + +### P1-12 [审批] 审批卡无 conversationId,多会话路由靠 activeConversationId 推断 ⚠️部分成立 +`useAiApproval.ts:65`,F-09 多会话并发下 A 后台审批卡渲染到 B 视图时点击路由不准(依赖多会话并发 + 后台审批叠加才触发)。 + +--- + +## P2(健壮性/可读性) + +- `useStreamRenderer.ts:139` tailSeq 局部失效 —— **功能没坏**(v-html 值变化兜底更新),仅注释「末块 key 每次不同」与实现(恒为 `tail-1`)相反,误导维护者。 +- `ApprovalOverlay.vue:61` `Date.now()` 在 computed 内,60s 冷却期不响应式过期。 +- `aiShared.ts:288-311` setConvStreaming / setConvCurrentText 收敛不对称,Map 项可能残留。 +- `ConversationSidebar.vue:255` 标题 flash watch 死代码(`if(oldTitle) return` 覆盖前一行)。 +- `useAiWindow.ts:309` maximized 分支 `targetHeight` 三元死代码(高度 vs 宽度比较)。 +- `useToolApproval.ts:29` `emit: any` 类型擦除。 +- `useAiContext.ts:29` `isCompressing` 模块单例,多窗口共享。 + +--- + +## ❌代理误报已否决(独立核验) + +- **~~[会话 P0] switchConversation currentText accessor 重定向~~**:代理4 称 `:206 state.currentText=''` 在两次 await 间被 accessor 重定向。**否决**——`:113` 设 `activeConversationId=B` 后到 `:206` 是同步连续代码(无 await),`:206` 之后才 `:212 await`。accessor 不会中途重定向,写的是正确的目标会话。代理4 时序分析错误。 + +--- + +## 根源分析 + +所有 P0/P1 的共同根因:**F-09 per-conv 改造未完成**。 + +| 状态源 | per-conv 化状态 | 串话表现 | +|---|---|---| +| streaming / currentText | ✅ 已 per-conv(DEC-07a accessor) | — | +| convStates(conv_state 真相源) | ✅ 已 per-conv(Map) | 但 watchdog legacy fallback 路径 `clear()` 全局误杀(P0-1) | +| queue | ❌ 全局单例 | 跨会话丢消息(P1-1)、强发错位(P1-4) | +| watchdog | ⚠️ 双轨(per-conv Map + legacy 单 timer) | legacy 路径不清 by 心跳、全局 clear(P0-1) | +| _approvalTimers | ❌ 全局 Map | 切/删会话不清(P0-2) | +| modelOverride | ❌ 模块级 ref | 切会话丢失手选(P1-3) | +| _lastDelta | ❌ 模块级单值 | 跨会话误丢 delta(P1-5) | + +--- + +## 建议修复优先级 + +1. **P0-1**(流式误杀,最高频可复现)—— legacy `clear()` 改 `delete(activeConversationId)` + 恢复 running 工具检测 +2. **P0-2**(审批误拒 + 错气泡)—— switch / delete 清 timer + 修正注释 +3. **P1-1 / P1-3 / P1-4**(单例债三连,可攒批修) +4. 其余 P1(渲染性能/死代码/防重入)按维度推进 +5. P2 择机清理 + +## 相关文件 + +- `src/composables/ai/useAiStream.ts`(P0-1 核心) +- `src/composables/ai/useAiSend.ts`(P0-1 reset / P1-1,2,4) +- `src/composables/ai/streamingGuard.ts`(P0-1 forceReset 同款 clear) +- `src-tauri/src/commands/ai/audit/mod.rs`(P0-1 后端心跳 30s) +- `src/composables/ai/useAiConversations.ts`(P0-2 / P1-11) +- `src/composables/ai/aiShared.ts`(P0-2 _approvalTimers / P2 收敛) +- `src/components/ai/TopBar.vue`(P1-3 双 watch) +- `src/components/ai/MessageList.vue`(P1-7,9) +- `src/components/ai/MessageItem.vue`(P1-7 死代码) +- `src/composables/ai/useStreamRenderer.ts`(P1-8 / P2 tailSeq) +- `src/composables/ai/useToolApproval.ts`(P1-10) diff --git a/docs/INDEX.md b/docs/INDEX.md index 46ac339..0f9b64c 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -108,7 +108,7 @@ docs/ ├── 08-用户指南/ │ ├── 使用手册-2026-06-12.md # 用户手册(对齐 06-15 代码基线) │ ├── AI文件操作工具手册.md # AI 工具 API 全量参考(10 工具,2026-06-16 更新) -│ └── patch_file使用指南.md # patch_file 专项使用指南 +│ ├── patch_file使用指南.md # patch_file 专项使用指南 └── 09-问题排查/ └── aichat-apikey-401排查-2026-06-15.md ``` diff --git a/docs/todo.md b/docs/todo.md index 71700c8..3ecac77 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -312,3 +312,15 @@ graph TD > ✅ 全部修复完成(2026-06-27 核验) - [~] **IDEA-FIX-10 [P2🟠]** — **前端 filter + 后端分页漏数据**。团队决策:`hot`/`pending` 保留前端 filter(扩多值属性过度设计);keyword/order_by 已下沉后端。 + +--- + +### 🔍 2026-07-17 aichat 对话功能深度走查待办 + +> 详单:[05-代码审查/aichat-对话功能走查-2026-07-17.md](./05-代码审查/aichat-对话功能走查-2026-07-17.md) + +- [x] **AIC-FIX-17-P0-1** — 流式 watchdog legacy 路径 `convStates.clear()` 全局误杀 → `delete(state.activeConversationId)` ✅ +- [x] **AIC-FIX-17-P0-2** — 审批计时器切/删会话不清 → switch/delete 入口 `clearAllApprovalTimers()` + 注释修正 ✅ +- [ ] **AIC-FIX-17-P1-1~11** — P1 队列/互斥/modelOverride/死代码/缓存/防重入/删除回落(待后续批次) +- [ ] **AIC-FIX-17-P2** — P2 健壮性项(待后续批次) +- [ ] **AIC-FIX-17-根因** — F-09 per-conv 收尾(queue / modelOverride / _approvalTimers / _lastDelta 单例化) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ab64d71..0c92aab 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ tauri-plugin-window-state = "2" serde.workspace = true serde_json.workspace = true tokio.workspace = true +dashmap.workspace = true anyhow.workspace = true tracing.workspace = true tracing-subscriber = { version = "0.3", features = ["env-filter"] } @@ -56,9 +57,10 @@ regex = { workspace = true } # 根因见 docs/09-问题排查/aichat-apikey-401排查-2026-06-15.md keyring = { workspace = true } # http_request AI 工具:结构化 HTTP(GET/POST/...)。 -# 复用 df-ai 同款 reqwest 0.12(同版本锁定,避免双 TLS 后端)。默认 native-tls(与 df-ai 一致), -# 启用 json(响应解析)/gzip/brotli(透明解压,常见 API 必备)。重定向手动接管(见 http.rs SSRF)。 -reqwest = { version = "0.12", features = ["json", "gzip", "brotli"] } +# 复用 df-ai 同款 reqwest 0.12(同版本锁定,避免双 TLS 后端)。 +# 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程 +# 致 BUG-2026-07-17(aichat 流式调用永久 hang,外层 tokio timeout 亦无法推进计时器)。 +reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls"] } # AST 代码智能(read_symbol 三态,信息密度驱动,见 docs/02-架构设计/专项设计/AST符号解析-设计-2026-06-24.md): # 治 aichat read_file 全文回灌 prompt 爆(e46f5605 360K/8dfe0b94 5M)。tree-sitter 语法层精准提取 # 符号骨架/下钻/全文,替代物理读全文件。静态编译 + 集中 grammar_for(ext) lookup(不动态加载/不抽 trait,YAGNI)。 diff --git a/src-tauri/src/commands/ai/agentic/conv_state.rs b/src-tauri/src/commands/ai/agentic/conv_state.rs index 8843aef..0d26a1c 100644 --- a/src-tauri/src/commands/ai/agentic/conv_state.rs +++ b/src-tauri/src/commands/ai/agentic/conv_state.rs @@ -191,6 +191,112 @@ impl std::fmt::Display for InvalidTransition { impl std::error::Error for InvalidTransition {} +// ============================================================ +// ConvStateStore — 无锁并发 ConvState 存储(session 锁重构方案 B-Phase0) +// +// 背景:AiSession 全局 Mutex 把 ConvState(高频读 + 敏感脏读)与 messages/pending_approvals +// (长持锁源)同锁串行化,致 guard.reset 等 lock 竞争 800ms fallback(AiCompleted 延迟 / 工具后 +// 中断 / 第二条进队列同源根因)。本 Store 把 ConvState 提到独立 DashMap,guard.reset/new/drop +// 直接 transition(同步无 await,不竞争 session lock),ai_is_generating 直接读(零锁竞争)。 +// +// 设计: +// - 基于 dashmap::DashMap(行级锁,不同 conv 不互斥,无 tokio runtime 阻塞) +// - 全方法同步无 await(transition 内仅 copy + write enum,纳秒级) +// - transition 经 ConvState::transition_to 守卫(复用状态机语义,非法转换拒绝) +// - get 对不存在的 conv_id 返 Idle(惰性默认,对齐 PerConvState 新建语义) +// +// 方案 B 分阶段迁移完成。 +// - Phase0~1: ConvStateStore 骨架 + AppState 接入 +// - Phase2~3: 写/读侧迁移至无锁 ConvStateStore,PerConvState.conv_state 字段已删 +// - Phase4: conversation_delete 同步清理 conv_states 条目 +// ============================================================ + +use dashmap::DashMap; + +/// ConvState 的无锁并发存储(方案 B 核心)。 +/// +/// 经 `Arc` 共享(app_state.conv_states)。所有方法同步无 await,可在任意 +/// async 上下文直接调(不竞争 session lock,不阻塞 tokio runtime)。 +/// +/// 注:`transition` 用 DashMap entry 原子(get + transition_to + write 一致,无 TOCTOU 窗口)。 +pub struct ConvStateStore { + inner: DashMap, +} + +impl ConvStateStore { + /// 创建空 Store。 + pub fn new() -> Self { + Self { inner: DashMap::new() } + } + + /// 读 conv_id 的 ConvState(不存在返 Idle 默认,对齐 PerConvState 新建语义)。 + pub fn get(&self, conv_id: &str) -> ConvState { + self.inner.get(conv_id).map(|r| *r.value()).unwrap_or(ConvState::Idle) + } + + /// 读 conv_id 是否活跃生成态(Generating/Compressed)—— ai_is_generating 零锁读。 + pub fn is_active(&self, conv_id: &str) -> bool { + self.get(conv_id).is_active() + } + + /// 读 conv_id 是否可接受新请求(Idle/Error)—— can_accept_request 零锁读。 + pub fn can_accept_request(&self, conv_id: &str) -> bool { + self.get(conv_id).can_accept_request() + } + + /// 原子迁移 conv_id 的 ConvState 到 target(经 transition_to 守卫)。 + /// + /// DashMap entry 原子(get + transition + write 一致,无 TOCTOU)。不存在的 conv_id 视为 + /// Idle(对齐新建语义),Idle→target 经守卫。返回 Ok(新态) 或 Err(InvalidTransition)。 + pub fn transition( + &self, + conv_id: &str, + target: ConvState, + ) -> Result { + // get_mut 持写锁原子迁移(Occupied);Vacant 时 insert(Idle 起步)。 + // 注:transition 调用点(guard.new/reset/drop)同 conv 单 loop 不并发,TOCTOU 风险低; + // 跨 conv 各自条目行级锁不互斥(对齐 DashMap 设计)。 + if let Some(mut r) = self.inner.get_mut(conv_id) { + let cur = *r.value(); + match cur.transition_to(target) { + Ok(ns) => { + *r.value_mut() = ns; + Ok(ns) + } + Err(e) => Err(e), + } + } else { + match ConvState::Idle.transition_to(target) { + Ok(ns) => { + self.inner.insert(conv_id.to_string(), ns); + Ok(ns) + } + Err(e) => Err(e), + } + } + } + + /// 删除 conv_id 的条目(会话删除时同步清,防已删 conv 残留 Generating 致 id 复用脏状态)。 + pub fn remove(&self, conv_id: &str) { + self.inner.remove(conv_id); + } + + /// 所有活跃生成态的 conv_id 快照(L0 握手批量 stop / 恢复生成态用)。 + pub fn active_convs(&self) -> Vec { + self.inner + .iter() + .filter(|r| r.value().is_active()) + .map(|r| r.key().clone()) + .collect() + } +} + +impl Default for ConvStateStore { + fn default() -> Self { + Self::new() + } +} + // ============================================================ // 单元测试(纯逻辑无 IO) // ============================================================ @@ -458,4 +564,60 @@ mod tests { assert!(msg.contains("Idle"), "Display 应含 from: {}", msg); assert!(msg.contains("Stopping"), "Display 应含 to: {}", msg); } + + // ---- ConvStateStore(方案 B-Phase0,无锁并发存储)---- + + #[test] + fn test_store_get_default_idle() { + let s = ConvStateStore::new(); + assert_eq!(s.get("conv-1"), ConvState::Idle, "不存在 conv 应返 Idle 默认"); + } + + #[test] + fn test_store_transition_occupied() { + let s = ConvStateStore::new(); + s.transition("conv-1", ConvState::Generating).unwrap(); + assert_eq!(s.get("conv-1"), ConvState::Generating); + s.transition("conv-1", ConvState::Idle).unwrap(); + assert_eq!(s.get("conv-1"), ConvState::Idle); + } + + #[test] + fn test_store_transition_guard_rejects() { + let s = ConvStateStore::new(); + s.transition("conv-1", ConvState::Generating).unwrap(); + // Generating → Stopping 合法 + s.transition("conv-1", ConvState::Stopping).unwrap(); + // Stopping → Generating 非法(须先回 Idle 再起) + assert!(s.transition("conv-1", ConvState::Generating).is_err()); + } + + #[test] + fn test_store_remove() { + let s = ConvStateStore::new(); + s.transition("conv-1", ConvState::Generating).unwrap(); + s.remove("conv-1"); + assert_eq!(s.get("conv-1"), ConvState::Idle, "remove 后应返 Idle 默认"); + } + + #[test] + fn test_store_is_active_and_can_accept() { + let s = ConvStateStore::new(); + assert!(!s.is_active("conv-1"), "Idle 不活跃"); + assert!(s.can_accept_request("conv-1"), "Idle 可接"); + s.transition("conv-1", ConvState::Generating).unwrap(); + assert!(s.is_active("conv-1"), "Generating 活跃"); + assert!(!s.can_accept_request("conv-1"), "Generating 不可接"); + } + + #[test] + fn test_store_active_convs() { + let s = ConvStateStore::new(); + s.transition("a", ConvState::Generating).unwrap(); + s.transition("b", ConvState::Idle).unwrap(); + s.transition("c", ConvState::Generating).unwrap(); + let mut active = s.active_convs(); + active.sort(); + assert_eq!(active, vec!["a".to_string(), "c".to_string()], "仅活跃 conv"); + } } diff --git a/src-tauri/src/commands/ai/agentic/guard.rs b/src-tauri/src/commands/ai/agentic/guard.rs index 250a7aa..064ba83 100644 --- a/src-tauri/src/commands/ai/agentic/guard.rs +++ b/src-tauri/src/commands/ai/agentic/guard.rs @@ -1,218 +1,88 @@ -//! B-260615-09: generating 状态 RAII guard —— 从 agentic/mod.rs 抽离(重构第一批,纯结构搬迁)。 +//! generating 状态 RAII guard。 //! -//! 行为零变更:仅文件位置移动,逻辑/字段/语义完全保留。 -//! 调用方(agentic/mod.rs run_agentic_loop)经 `use super::guard::GeneratingGuard;` 复用。 - -use std::sync::Arc; +//! B-Phase2: ConvState 切无锁 ConvStateStore(不竞争 session lock,删 800ms fallback)。 +//! 治卡死连环:AiCompleted 延迟 / 工具后中断 / 第二条进队列同源根因。 use tauri::{AppHandle, Emitter, Manager}; -use tokio::sync::Mutex; -use crate::commands::ai::{AiChatEvent, AiSession}; +use crate::commands::ai::AiChatEvent; -// 双轨收口批2:CONV_STATE_ENABLED 常量删除后,模块路径(self)不再使用,仅导入 ConvState 类型。 use super::conv_state::ConvState; -/// generating 复位 RAII guard,取代散布的手动 `session.generating = false`。 +/// generating 复位 RAII guard。 /// -/// 两路复位: -/// - 正常路径:exit 点显式 `reset().await` 即时复位(emit 前调,保证"复位→emit"顺序, -/// 前端收事件时后端已可接下一条)。 -/// - 异常路径(panic/未走正常 return):Drop 兜底 spawn 复位,防 generating 永真卡死前端。 -/// -/// 注:try_continue_agent_loop 不用 guard——其 should_continue=false 路径需保持 -/// generating=true(审批等待态),全函数 guard 会误复位;该函数单点 provider-Err 复位保持手动。 -/// -/// F-260616-09 B 批4:guard 持 `conv_id`,复位改写 `session.conv(&conv_id).generating = false` -/// (per-conv 唯一真相源)。顶层 `session.generating` 字段已在批4 删除,reset/Drop 仅写 per_conv; -/// IPC(ai_is_generating/ai_chat_send 等)亦改读 per_conv,无需双写桥接。 -/// -/// L2 统一状态机收尾(2026-06-22):本地 `state: Option` 视图层冗余字段已删除。 -/// -/// 背景:批1 曾在 guard 内嵌 `state` 作「写收敛视图层」(双轨过渡),批2 已把 `ConvState` 落到 -/// `PerConvState.conv_state` 字段持久化(读侧真相源)。批2 同时在 `reset`/`drop` 内读 -/// `conv.conv_state` 持久化字段做迁移 + emit AiConvStateChanged——故本地 `state` 成纯冗余 -/// (双源,且本地 state 在 Drop 无 await 上下文无法持久化,语义本就不完整)。 -/// -/// 收尾删除后: -/// - `new`:读 `conv.conv_state` 持久化字段,若 Idle→Generating 迁移成功则 emit 通知前端 -/// (入口 `run_agentic_loop` 已独立写持久化迁移但**不 emit**,故此处补 emit 防前端漏收生成态)。 -/// - `reset`/`drop`:迁移 + emit 均读 `conv.conv_state` 持久化字段(批2 已落地,本批仅删冗余本地块)。 -/// - `disarm`:不动 ConvState(审批等待是 Generating 内的暂停点)。 -/// -/// 批3 双轨收口:`CONV_STATE_ENABLED` 开关与 `generating` bool 已退役,enum 迁移 + emit -/// 无条件执行(单一真相源,无 off 降级分支)。 +/// ConvState 经无锁 [`ConvStateStore`](super::conv_state::ConvStateStore) 迁移(不竞争 session +/// lock)。正常退出显式 `reset()`;异常退出 `Drop` 兜底迁移 Idle。`disarm` 解除 Drop 兜底 +/// (审批等待保持 Generating,续跑新 guard.new 重新迁移)。 pub(super) struct GeneratingGuard { - session: Arc>, - /// guard 所属会话(loop 启动时快照的 conv_id,来自 run_agentic_loop 入参)。 conv_id: String, done: bool, - /// L2 批2 1b:emit AiConvStateChanged 用的 AppHandle(Clone 廉价,run_agentic_loop 入参传入)。 - /// 批3 收口:emit 无条件执行(开关已退役),此字段恒被消费。 app_handle: AppHandle, } impl GeneratingGuard { - pub(super) fn new(session: Arc>, conv_id: String, app_handle: AppHandle) -> Self { - // L2 收尾:入口 `run_agentic_loop` 已独立完成 Idle→Generating 的持久化迁移(写 conv.conv_state), - // 但**不 emit** AiConvStateChanged。此处补 emit 通知前端进入生成态——读 conv.conv_state - // 持久化字段(真相源)做迁移校验,迁移成功则 emit,非法记 warn 不阻断。 - // - // 注:new 是同步函数(无 await),无法持 session 锁读 conv.conv_state;此处经 - // tauri::async_runtime::spawn 异步补 emit(非阻塞 guard 创建,入口持久化已发生故 emit 有据)。 - // spawn 失败(运行时不可用,理论场景)仅漏一次 emit,前端 watchdog 兜底(对齐失败不阻断核心生成)。 - // 批3 双轨收口:删除 CONV_STATE_ENABLED 门控,迁移 + emit 无条件执行(enum 单一真相源)。 - // 注:clone 给闭包 move,原始 session/app_handle 留给 Self 字段(对齐原 if 块外 Self 构造)。 - let session_for_spawn = session.clone(); - let app_handle_for_spawn = app_handle.clone(); - let cid = conv_id.clone(); - tauri::async_runtime::spawn(async move { - let new_state = { - let mut session = session_for_spawn.lock().await; - let conv = session.conv(&cid); - match conv.conv_state.transition_to(ConvState::Generating) { - Ok(ns) => { - conv.conv_state = ns; - ns - } - Err(e) => { - tracing::warn!( - conv_id = %cid, - error = %e, - "[ai] guard.new ConvState→Generating 非法(持久化层,不阻断核心生成)" - ); - return; - } - } - }; - // 持久化层迁移成功后 emit 前端(同步,失败忽略)。锁已随作用域 drop,可安全 emit。 - // L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 - let ev = AiChatEvent::AiConvStateChanged { - conv_state: new_state, - conversation_id: Some(cid), - }; - let _ = app_handle_for_spawn.emit("ai-chat-event", ev.clone()); - let _ = app_handle_for_spawn.state::().ai_event_bus.publish_event(ev); - }); - Self { session, conv_id, done: false, app_handle } + pub(super) fn new(conv_id: String, app_handle: AppHandle) -> Self { + let guard = Self { + conv_id, + done: false, + app_handle: app_handle.clone(), + }; + guard.transition(ConvState::Generating); + guard } - /// 显式复位生成态。emit 前调用保证顺序。幂等。 - /// - /// 批3 双轨收口:generating bool 已退役,复位经 ConvState 迁移(Generating→Idle)单一表达。 - /// L2:同步迁移 `conv.conv_state → Idle`(持久化层,迁移 + emit 无条件执行)。 + /// 复位 Idle(emit 前调,保证前端收事件时后端已 Idle)。幂等。 pub(super) async fn reset(&mut self) { if !self.done { - let mut session = self.session.lock().await; - let conv = session.conv(&self.conv_id); - // L2 批2:ConvState 持久化(Generating→Idle 写收敛)。批3 删除开关门控,迁移无条件执行。 - match conv.conv_state.transition_to(ConvState::Idle) { - Ok(ns) => { - conv.conv_state = ns; - // L2 批2 1b:持久化层 Idle 收敛成功后 emit 前端(同步,失败忽略)。 - // L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 - let ev = AiChatEvent::AiConvStateChanged { - conv_state: ns, - conversation_id: Some(self.conv_id.clone()), - }; - let _ = self.app_handle.emit("ai-chat-event", ev.clone()); - let _ = self.app_handle.state::().ai_event_bus.publish_event(ev); - } - Err(e) => tracing::warn!( - conv_id = %self.conv_id, - error = %e, - "[ai] guard.reset ConvState→Idle 非法(持久化层,不阻断核心复位)" - ), - } self.done = true; + self.transition(ConvState::Idle); } } - /// 解除 Drop 兜底复位但不复位 generating。审批等待 return 路径调用: - /// 保持 generating=true 留 try_continue 续生成,同时 Drop 因 done=true 跳过复位 spawn。 - /// (B-260615-26: 修复审批执行后对话不续生成回归) - /// - /// L2:disarm 不迁移 ConvState(审批等待是 Generating 内的暂停点,ConvState 仍 Generating, - /// 与「generating 保持 true」语义一致)。续跑时新 guard.new 重新迁移(Generating 自环幂等)。 + /// 解除 Drop 兜底(审批等待保持 Generating)。 pub(super) fn disarm(&mut self) { self.done = true; } + + /// 迁移 ConvState + emit AiConvStateChanged(经无锁 ConvStateStore,同步无 await)。 + fn transition(&self, target: ConvState) { + let conv_states = self + .app_handle + .state::() + .conv_states + .clone(); + let ev = match conv_states.transition(&self.conv_id, target) { + Ok(ns) => Some(AiChatEvent::AiConvStateChanged { + conv_state: ns, + conversation_id: Some(self.conv_id.clone()), + }), + Err(e) => { + tracing::warn!( + conv_id = %self.conv_id, + error = %e, + "[ai] guard ConvState 迁移非法" + ); + None + } + }; + if let Some(ev) = ev { + let _ = self.app_handle.emit("ai-chat-event", ev.clone()); + let _ = self + .app_handle + .state::() + .ai_event_bus + .publish_event(ev); + } + } } impl Drop for GeneratingGuard { fn drop(&mut self) { - // 未 done 即异常退出(panic/未走正常 return):核心生成态经 spawn 复位, - // ConvState 持久化迁移 + emit 也在同一 spawn 内异步完成(无 await 上下文,对齐核心复位路径)。 - // done=true(正常 reset/disarm 已走) → 跳过。批3 收口后无开关 off 分支。 if !self.done { - let session = self.session.clone(); - let conv_id = self.conv_id.clone(); - // L2 批2 1b:Drop 无 await 上下文,持久化迁移 + emit 在 spawn 内异步完成(对齐核心复位 spawn 路径)。 - // app_handle Clone 廉价,随 session/conv_id 一起 move 进 async 块。 - let app_handle = self.app_handle.clone(); - tauri::async_runtime::spawn(async move { - let mut s = session.lock().await; - let conv = s.conv(&conv_id); - // L2 批2:ConvState 持久化(异常退出兜底→Idle)。批3 删除开关门控 + generating bool 写,迁移无条件执行。 - if let Ok(ns) = conv.conv_state.transition_to(ConvState::Idle) { - conv.conv_state = ns; - // 1b:持久化 Idle 收敛后 emit 前端(异常退出兜底也需通知前端退出生成态)。 - // L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 - let ev = AiChatEvent::AiConvStateChanged { - conv_state: ns, - conversation_id: Some(conv_id.clone()), - }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); - } - }); + self.transition(ConvState::Idle); } } } -// ============================================================ -// 单元测试(guard ConvState 迁移语义,纯逻辑路径验证) -// ============================================================ - -#[cfg(test)] -mod tests { - use super::*; - - /// guard.new 路径:持久化层 Idle→Generating 迁移(读 conv.conv_state)。 - /// - /// 注:不构造真实 AiSession(lock/Mutex 建会话状态成本高),仅校验迁移语义—— - // guard 内部 transition_to 是纯函数,直接断言逻辑路径。 - #[test] - fn test_guard_new_migrates_to_generating() { - // 批3 收口后无开关断言:Idle → Generating(读侧持久化字段迁移路径,无条件执行)。 - let mut s = ConvState::Idle; - s = s.transition_to(ConvState::Generating).unwrap(); - assert_eq!(s, ConvState::Generating); - } - - /// reset 迁移 Generating→Idle(正常收敛),验证迁移合法。 - #[test] - fn test_guard_reset_migrates_to_idle() { - let mut s = ConvState::Generating; - s = s.transition_to(ConvState::Idle).unwrap(); - assert_eq!(s, ConvState::Idle); - } - - /// 异常 Drop 路径同样 Generating→Idle(兜底),验证迁移合法。 - #[test] - fn test_guard_drop_path_migrates_to_idle() { - // guard.drop 异常路径(!done)迁移 Generating→Idle(读持久化字段)。 - let mut s = ConvState::Generating; - s = s.transition_to(ConvState::Idle).unwrap(); - assert_eq!(s, ConvState::Idle); - } - - /// disarm 不迁移状态(审批等待保持 Generating)。 - /// 验证:disarm 后 enum 仍 Generating(下次 new 时 Generating 自环幂等通过)。 - #[test] - fn test_guard_disarm_keeps_generating_then_self_loop_on_new() { - let s = ConvState::Generating; - // disarm 不动 enum;续跑新 guard.new 时 Generating→Generating 自环通过。 - assert_eq!(s.transition_to(ConvState::Generating), Ok(ConvState::Generating)); - } -} +// ConvState 迁移语义单测见 conv_state.rs(ConvStateStore + ConvState::transition_to)。 +// guard struct 委托 ConvStateStore,无独立逻辑,不重复单测。 diff --git a/src-tauri/src/commands/ai/agentic/helpers.rs b/src-tauri/src/commands/ai/agentic/helpers.rs index de5102b..e4bd9be 100644 --- a/src-tauri/src/commands/ai/agentic/helpers.rs +++ b/src-tauri/src/commands/ai/agentic/helpers.rs @@ -30,11 +30,12 @@ pub async fn try_continue_agent_loop( let snap = { let session = state.ai_session.lock().await; - let has_pending = session.session_state(conv_id) == SessionState::AwaitingApproval; + let has_pending = session.session_state(conv_id, &state.conv_states) == SessionState::AwaitingApproval; let pending_conv_id = session.pending_approvals.values() .find_map(|a| a.conversation_id.clone()); let conv = session.conv_read(conv_id); - let is_generating = conv.map(|c| c.conv_state.is_active()).unwrap_or(false); + // B-Phase2:ConvState 读侧切无锁 conv_states(is_active),不再占 session lock 读 conv_state。 + let is_generating = state.conv_states.is_active(conv_id); let agent_language = conv.and_then(|c| c.agent_language.clone()); let model_override = conv.and_then(|c| c.model_override.clone()); let pinned_goals_snapshot = conv.map(|c| c.pinned_goals.clone()).unwrap_or_default(); @@ -73,12 +74,10 @@ pub async fn try_continue_agent_loop( let provider_config = match get_active_provider(state).await { Ok(p) => p, Err(e) => { - let mut session = state.ai_session.lock().await; - match session.conv(conv_id).conv_state.transition_to(ConvState::Idle) { - Ok(ns) => session.conv(conv_id).conv_state = ns, - Err(e2) => tracing::warn!(conv_id = %conv_id, error = %e2, "ConvState→Idle 非法"), + // B-Phase3:不再持锁操作 conv_state(ConvStateStore 无锁单源)。 + if let Err(e2) = state.conv_states.transition(conv_id, ConvState::Idle) { + tracing::warn!(conv_id = %conv_id, error = %e2, "conv_states→Idle 非法"); } - drop(session); let _ = app.emit("ai-chat-event", AiChatEvent::AiError { error: e.clone(), error_type: Some(ErrorType::ProviderConfig), conversation_id: Some(conv_id.to_string()), @@ -107,10 +106,8 @@ pub async fn try_continue_agent_loop( let max_retries = state.agent_max_retries.load(std::sync::atomic::Ordering::SeqCst); let model_override = snap.model_override.clone(); - let still_generating = { - let session = state.ai_session.lock().await; - session.conv_read(conv_id).map(|c| c.conv_state.is_active()).unwrap_or(false) - }; + // B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,无需 session lock)。 + let still_generating = state.conv_states.is_active(conv_id); if !still_generating { let ev = AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, diff --git a/src-tauri/src/commands/ai/agentic/knowledge_lifecycle.rs b/src-tauri/src/commands/ai/agentic/knowledge_lifecycle.rs index 932a28f..c3dc6be 100644 --- a/src-tauri/src/commands/ai/agentic/knowledge_lifecycle.rs +++ b/src-tauri/src/commands/ai/agentic/knowledge_lifecycle.rs @@ -1,32 +1,2 @@ //! Agentic 循环知识生命周期(文档占位)。 -//! -//! ## 为什么没有抽取 -//! -//! `run_agentic_loop` 中知识相关的逻辑**已经是模块化的**:所有调用点都是对已抽取函数的 -//! 单行调用,不存在可内联抽取的知识逻辑。 -//! -//! ### 已存在的知识模块 -//! -//! 知识注入/提炼实现位于 [`super::super::knowledge_inject`] 模块,`mod.rs` 顶部已通过 -//! `use super::knowledge_inject::{inject_knowledge_into_prompt, maybe_spawn_extraction};` 引入: -//! -//! - `inject_knowledge_into_prompt` —— 构建请求前把知识库上下文注入 system_prompt。 -//! - `maybe_spawn_extraction` —— 后台知识提炼(需读已落库的对话消息)。 -//! -//! ### mod.rs 中的调用点(均为单行) -//! -//! | 行 | 所在函数 | 调用 | -//! |----|----------|------| -//! | ~1853 | `run_agentic_loop`(正常完成后台 spawn) | `maybe_spawn_extraction(...)` | -//! | ~2092 | `try_continue_agent_loop` | `inject_knowledge_into_prompt(...)` | -//! -//! 注意:`run_agentic_loop` 入口的 system_prompt 构建**不**在 mod.rs 内联调用 -//! `inject_knowledge_into_prompt` —— 知识注入发生在调用方(IPC commands.rs / -//! try_continue_agent_loop),loop 入参拿到的 `system_prompt` 已是注入后的最终值。 -//! 故 mod.rs 内知识相关代码仅有上述 2 处单行调用。 -//! -//! ### 结论 -//! -//! 知识生命周期的抽取工作**早已完成**(knowledge_inject 模块独立)。本文件仅作占位与说明, -//! 无代码可移。`inject_knowledge_into_prompt` 的入口收敛(DRY B 改进)已在调用方完成, -//! 非 mod.rs 职责。 +//! 知识提炼实现位于 `super::super::knowledge_inject` 模块。 diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index c96838b..7d6d5e4 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -46,16 +46,9 @@ use super::audit::process_tool_calls; // compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。 use super::conversation::{save_conversation, TokenAccumulator}; use super::knowledge_inject::{maybe_spawn_extraction}; -#[allow(unused_imports)] -use super::knowledge_inject::inject_knowledge_into_prompt; -#[allow(unused_imports)] -use super::prompt::{build_system_prompt, get_active_provider}; use super::stream_recv::{stream_llm, StreamResult}; use super::title::{ensure_conversation_title, spawn_ensure_title}; -#[allow(unused_imports)] -use super::{AiChatEvent, AiSession, ErrorType, SessionState}; -#[allow(unused_imports)] -use super::ToolCallDraft; +use super::{AiChatEvent, AiSession, ErrorType}; // ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。 /// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。 @@ -356,14 +349,12 @@ async fn stream_one_provider( // 注:primary 候选的启动 Auth 早失败已在外层 run_agentic_loop 顶部处理(emit + return), // 本函数到达时 primary 的 key 已验证过;此处 Err 多见于 secondary 配置不一致, // 保守归 Fatal 立即放弃(不浪费预算试下一 provider,因 key 错非瞬态)。 - let provider: Box = match super::secret::build_provider_for(candidate) { + let provider: Arc = Arc::from(match super::secret::build_provider_for(candidate) { Ok(p) => p, Err(msg) => { - // UX-260618-15: Fatal 分支 emit 移到外层(stream_one_provider 调用方 Fatal 分支统一 emit)。 - // 此处仅返回 error 文本,避免 stream_one_provider 内 emit 与外层 emit 重复(Fatal 终态单 emit)。 return StreamOutcome::Fatal { error: msg }; } - }; + }); // resolved_model 在本 candidate.model_configs 上重算(F-260614-04b 核心:provider 切换后 // 模型池不同,必须重选;否则拿主 provider 的 model_id 去打次 provider 会吃 400/404)。 @@ -394,7 +385,7 @@ async fn stream_one_provider( reasoning_content: last_reasoning_content.clone(), }; - match stream_llm(&*provider, retry_request, app_handle, stop_flag, notify, conv_id).await { + match stream_llm(Arc::clone(&provider), retry_request, app_handle, stop_flag, notify, conv_id).await { StreamResult::Complete { text, tool_calls, usage, reasoning_content } => { return StreamOutcome::Success { text, tool_calls, usage, @@ -514,7 +505,34 @@ pub(crate) async fn run_agentic_loop( // B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底) // F-260616-09 B 批2:guard 持 conv_id,复位改 per-conv.generating(设计 §4.3)。 // L2 批2 1b:guard 持 app_handle,ConvState 迁移后 emit AiConvStateChanged 推前端。 - let mut guard = GeneratingGuard::new(session_arc.clone(), conv_id.clone(), app_handle.clone()); + let mut guard = GeneratingGuard::new(conv_id.clone(), app_handle.clone()); + + // 治本:LLM 生成间隙向前端发 AiHeartbeat,防前端 watchdog 误断流。 + // 后端 emit AiHeartbeat → useAiEvents.ts resetStreamWatchdog(convId) → per-conv timer 重置。 + // 解决:LLM 重试循环(~60s)期间无事件到达前端 → watchdog 45s/90s 到期误杀。 + // shutdown_tx 在函数作用域结束(Drop)时通知 heartbeat 任务退出。 + let (_heartbeat_tx, mut heartbeat_rx) = tokio::sync::watch::channel(()); + let hb_app = app_handle.clone(); + let hb_conv_id = conv_id.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + _ = heartbeat_rx.changed() => { + // watch 通道关闭(发送端 Drop)→ 退出 + break; + } + _ = tokio::time::sleep(std::time::Duration::from_secs(20)) => { + // 每 20s 发一次 AiHeartbeat,前端重置 watchdog timer。 + let _ = hb_app.emit( + "ai-chat-event", + AiChatEvent::AiHeartbeat { + conversation_id: Some(hb_conv_id.clone()), + }, + ); + } + } + } + }); // F-260616-09 B 批2 入口桥接:loop 启动前确保 per_conv 存在(已存在则保留累积,不存在则建)。 // @@ -533,23 +551,7 @@ pub(crate) async fn run_agentic_loop( // 单 loop 安全性:批2 阶段是单 active_conversation_id(决策 e 真并发批3+ 落地),无并发 loop 抢 // per_conv 覆盖。批3+ 多 loop 并发时,每 conv 各自 per_conv 条目,互不干扰(本桥接无需改)。 // - // L2 状态机写收敛:ConvState 的写收敛已收敛到 `guard`(GeneratingGuard::new 已在上方 L399 创建, - // new 内部 Idle→Generating 迁移 + reset/drop Generating→Idle 迁移)。 - // 批3 双轨收口:generating bool 已退役,此处仅做持久化层 Idle→Generating 迁移(单一真相源), - // emit 由 guard.new 异步补发(入口迁移但不 emit,guard 补 emit 防前端漏收生成态)。 - { - let mut session = session_arc.lock().await; - let conv = session.conv(&conv_id); - // L2 批2:ConvState 持久化(Idle→Generating 写收敛)。批3 删除开关门控,迁移无条件执行。 - match conv.conv_state.transition_to(conv_state::ConvState::Generating) { - Ok(ns) => conv.conv_state = ns, - Err(e) => tracing::warn!( - conv_id = %conv_id, - error = %e, - "[ai] 入口 ConvState→Generating 非法(状态机持久化层,不阻断核心生成)" - ), - } - } + // 入口:ConvState 迁移由 guard.new 在无锁 ConvStateStore 上完成。 // F-260614-04 / F-260614-04b: 多 Provider 负载均衡池 — 选主 + fallback 候选列表。 // @@ -1544,10 +1546,7 @@ pub(crate) async fn run_agentic_loop( let _ = converged; // 处理工具调用(Low 自动执行 / Medium+High 待审批) - let pending_count = { - let mut session = session_arc.lock().await; - process_tool_calls(&mut session, tool_calls_acc, &tools_arc, &db, &app_handle, &conv_id).await - }; + let pending_count = process_tool_calls(&session_arc, tool_calls_acc, &tools_arc, &db, &app_handle, &conv_id).await; // F-260620 卡死已根治(DIRAUTH 审批链已闭环)。原 eprintln 诊断降级为 tracing::debug, // 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。 tracing::debug!( diff --git a/src-tauri/src/commands/ai/agentic/title_lifecycle.rs b/src-tauri/src/commands/ai/agentic/title_lifecycle.rs index d277c06..ffb3fb9 100644 --- a/src-tauri/src/commands/ai/agentic/title_lifecycle.rs +++ b/src-tauri/src/commands/ai/agentic/title_lifecycle.rs @@ -1,34 +1,2 @@ //! Agentic 循环标题生命周期(文档占位)。 -//! -//! ## 为什么没有抽取 -//! -//! `run_agentic_loop` 中标题相关的逻辑**已经是模块化的**:所有调用点都是对已抽取函数的 -//! 单行调用,不存在可内联抽取的标题逻辑。 -//! -//! ### 已存在的标题模块 -//! -//! 标题生成实现位于 [`super::super::title`] 模块,`mod.rs` 顶部已通过 -//! `use super::title::{ensure_conversation_title, spawn_ensure_title};` 引入: -//! -//! - `spawn_ensure_title` —— 后台 fire-and-forget 标题生成(不阻塞 Completed emit)。 -//! - `ensure_conversation_title` —— 标题生成(可 await,正常完成路径后台 spawn 内调用)。 -//! -//! ### mod.rs 中的调用点(均为单行) -//! -//! `run_agentic_loop` 内 4 处调用: -//! -//! | 行 | 场景 | 调用 | -//! |----|------|------| -//! | ~893 | 入口 stop(首轮即停) | `spawn_ensure_title(...)` | -//! | ~1469 | MidStream 保文后 | `spawn_ensure_title(...)` | -//! | ~1543 | 流后 stop | `spawn_ensure_title(...)` | -//! | ~1856 | 正常完成后台 spawn | `ensure_conversation_title(...)` | -//! -//! 这些调用点散落在不同的控制流分支(stop/保文/正常完成),每处仅一行,且各自需要 -//! 不同的上下文(`resolved_model` 是否已知、是否在 guard.reset 前/后)。强行抽取为 -//! 单一函数会引入等量的参数透传 + 控制流分支,无净收益且增加间接层。 -//! -//! ### 结论 -//! -//! 标题生命周期的抽取工作**早已完成**(title 模块独立)。本文件仅作占位与说明, -//! 无代码可移。若未来需要统一标题调用上下文,应在 title 模块内封装,而非在此处。 +//! 标题生成实现位于 `super::super::title` 模块。 diff --git a/src-tauri/src/commands/ai/audit/approval.rs b/src-tauri/src/commands/ai/audit/approval.rs index 1b8352f..ba889e3 100644 --- a/src-tauri/src/commands/ai/audit/approval.rs +++ b/src-tauri/src/commands/ai/audit/approval.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; +use tokio::sync::Mutex; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_ai::provider::ChatMessage; @@ -91,20 +92,24 @@ pub(super) fn should_auto_for_persona( /// /// 首批信任工具:write_file / run_command。同会话已批准过同工具+同目录 → /// `TrustKey` 命中,返回 `Some(TrustKey)`;否则返回 `None`(走原审批流程)。 -pub(super) fn check_trust_hits( +/// +/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc>`,内部短 lock 读 +/// `session_trust` 后立即 drop,信任查询不持锁,与 process_tool_calls 持锁 await 反模式解耦。 +pub(super) async fn check_trust_hits( draft: &ToolCallDraft, args: &serde_json::Value, - session: &AiSession, + session_arc: &Arc>, conv_id: &str, ) -> Option { - trust_key_for(&draft.name, args) - .and_then(|key| { - if session.conv_read(conv_id).map(|c| c.session_trust.contains(&key)).unwrap_or(false) { - Some(key) - } else { - None - } - }) + let key = trust_key_for(&draft.name, args)?; + // 短 lock 读 session_trust(仅 contains 判定,无 await,纳秒级),命中即返 key + let hit = { + let session = session_arc.lock().await; + session.conv_read(conv_id) + .map(|c| c.session_trust.contains(&key)) + .unwrap_or(false) + }; + if hit { Some(key) } else { None } } /// 插入 pending 审批:生成 diff → 重试检测 → insert PendingApproval + 占位 tool_result @@ -112,11 +117,15 @@ pub(super) fn check_trust_hits( /// /// **注意**:调用方应在调用前先 +=1 `pending_count`(保持与原 `handle_approval_tool` /// 行为一致——重试 skip 分支也在 `pending_count += 1` 之后返回)。 +/// +/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc>`,所有慢操作 +/// (build_write_file_diff/detect_retry_count/build_approval_reason/audit_tool_call) +/// 在锁外 await,仅 `pending_approvals.insert` + `messages.push` 两处纯写改短 lock 段。 pub(super) async fn insert_pending_approval( draft: ToolCallDraft, args: serde_json::Value, risk_level: RiskLevel, - session: &mut AiSession, + session_arc: &Arc>, conv_id: &str, audit_repo: &AiToolExecutionRepo, app_handle: &AppHandle, @@ -144,7 +153,11 @@ pub(super) async fn insert_pending_approval( "已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)", draft.id ); - session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg)); + // 短 lock 段:push tool_result(纯写,无 await) + { + let mut session = session_arc.lock().await; + session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg)); + } // L3 emit 双写:重试 guard 跳过 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), @@ -157,19 +170,24 @@ pub(super) async fn insert_pending_approval( return; } - session.pending_approvals.insert(draft.id.clone(), PendingApproval { - tool_call_id: draft.id.clone(), - tool_name: draft.name.clone(), - arguments: args.clone(), - conversation_id: Some(conv_id.to_string()), - recovered: false, - // 阶段3a:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。 - kind: ApprovalKind::Risk { diff: approval_diff.clone() }, - retry_count, - created_at: Some(std::time::SystemTime::now()), - }); - // 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan) - session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id))); + // 短 lock 段:纯写 pending_approvals.insert + 占位 tool_result push + { + let mut session = session_arc.lock().await; + session.pending_approvals.insert(draft.id.clone(), PendingApproval { + tool_call_id: draft.id.clone(), + tool_name: draft.name.clone(), + arguments: args.clone(), + conversation_id: Some(conv_id.to_string()), + recovered: false, + // 阶段3a:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。 + kind: ApprovalKind::Risk { diff: approval_diff.clone() }, + retry_count, + created_at: Some(std::time::SystemTime::now()), + }); + // 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan) + session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id))); + } + // 慢操作锁外:拼 reason(DB 读)+ emit + 审计落 pending 纪录(DB 写) let reason = build_approval_reason(&draft.name, &args, risk_level, db).await; // L3 emit 双写:Med/High 风险审批挂起 AiApprovalRequired 双路发布(tunnel 透传 miniapp 弹审批窗)。 let ev = AiChatEvent::AiApprovalRequired { @@ -225,13 +243,16 @@ pub(super) async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id: /// 2. 否则走审批分支:会话信任(`check_trust_hits`)→ F-05 高危去重缓存 → 阶段4 重试 guard → /// `insert_pending_approval`(write_file diff + 挂起 + emit + 审计落 pending 记录)。 /// -/// **不持 session 锁**:调用方(process_tool_calls)在持锁循环内调用本函数。 +/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc>`,所有 session 访问 +/// 都改短 lock 段(check_trust_hits/find_cached_high_risk_result 内部短 lock,命中后 push +/// tool_result 短 lock 段)。慢操作(audit_tool_call/find_cached_high_risk_result 的 DB 查) +/// 全在锁外 await,根治 process_tool_calls 持 session lock 期间 await 慢操作死锁反模式。 pub(super) async fn handle_approval_tool( draft: ToolCallDraft, args: serde_json::Value, risk_level: RiskLevel, auto_exec_mode: &str, - session: &mut AiSession, + session_arc: &Arc>, conv_id: &str, _tools_arc: &Arc, audit_repo: &AiToolExecutionRepo, @@ -254,7 +275,7 @@ pub(super) async fn handle_approval_tool( // 命中后走与 F-05 去重命中相似的「直接执行 + Completed + 审计 decided_by=auto_trust」路径, // 但与 F-05 不同:F-05 复用缓存 tool_result 跳过执行;trust 放行**真实执行工具** // (用户信任同目录同类操作,但仍要看每次的真实结果)。 - if let Some(key) = check_trust_hits(&draft, &args, session, conv_id) { + if let Some(key) = check_trust_hits(&draft, &args, session_arc, conv_id).await { let dir_label = match &key { TrustKey::Write { dir } | TrustKey::Execute { dir } => dir.clone(), }; @@ -282,14 +303,20 @@ pub(super) async fn handle_approval_tool( // ── Step 3: F-05 高危去重缓存(仅 High) ── if matches!(risk_level, RiskLevel::High) { - if let Some((cached, status)) = find_cached_high_risk_result(session, conv_id, audit_repo, &draft.name, &args).await { + // find_cached_high_risk_result 内部短 lock 读 messages + 锁外 await DB 查 status, + // 返回 (cached_content, status) 时不持锁 + if let Some((cached, status)) = find_cached_high_risk_result(session_arc, conv_id, audit_repo, &draft.name, &args).await { // 命中:把缓存结果作为新 tool_call_id 的 tool_result 回传,跳过审批 tracing::info!( tool = %draft.name, new_tool_call_id = %draft.id, "[F-05] 高危工具去重命中:LLM 重试同命令,复用缓存结果跳过审批(断循环)" ); - session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached)); + // 短 lock 段:push tool_result(纯写,无 await) + { + let mut session = session_arc.lock().await; + session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached)); + } // L3 emit 双写:高危去重命中复用缓存 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), @@ -307,7 +334,7 @@ pub(super) async fn handle_approval_tool( // ── Step 4: 插入 pending 审批 ── *pending_count += 1; insert_pending_approval( - draft, args, risk_level, session, conv_id, audit_repo, app_handle, db, + draft, args, risk_level, session_arc, conv_id, audit_repo, app_handle, db, current_message_id, ).await; } diff --git a/src-tauri/src/commands/ai/audit/cache.rs b/src-tauri/src/commands/ai/audit/cache.rs index 9a26a9c..2532cce 100644 --- a/src-tauri/src/commands/ai/audit/cache.rs +++ b/src-tauri/src/commands/ai/audit/cache.rs @@ -4,8 +4,11 @@ //! + `sort_object_keys` + `PENDING_APPROVAL_PLACEHOLDER`(去重与 process_tool_calls //! 共用占位常量)。三函数内部闭环(find_cached 调 canonical→sort),无外部 helper 依赖。 +use std::sync::Arc; + use df_ai::provider::ChatMessage; use df_storage::crud::AiToolExecutionRepo; +use tokio::sync::Mutex; use super::super::AiSession; @@ -70,7 +73,7 @@ pub(crate) fn is_pending_placeholder(content: &str) -> bool { /// F-260616-09 B 批2:经`conv_id` 索引 per_conv.messages(顶层 messages 批2 后是死字段)。 /// conv_id 来源:process_tool_calls 入参 → 由 agentic/mod.rs run_agentic_loop 入参透传。 pub(crate) async fn find_cached_high_risk_result( - session: &AiSession, + session_arc: &Arc>, conv_id: &str, audit_repo: &AiToolExecutionRepo, tool_name: &str, @@ -81,70 +84,78 @@ pub(crate) async fn find_cached_high_risk_result( // 规范化新调用的 args 为可比字符串(排序键,键序无关) let new_args_key = canonical_args_key(args); - // F-260616-09 B 批2:读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv, - // 故 conv_read 必命中;防御性 None 时返 None(无缓存命中,走原审批流程)。 - let conv = session.conv_read(conv_id)?; - // ContextManager::iter 返回 impl Iterator(非 DoubleEnded),collect 成 Vec 再反向遍历。 - // 单对话消息量小(百级),collect 开销可忽略。 - let msgs: Vec<&ChatMessage> = conv.messages.iter().collect(); + // BUG-260624-03/P0 重构:短 lock 段读 messages + 找旧 tool_call_id + 旧 tool_result content, + // drop 锁后再锁外 await DB 查 status(原代码持锁 await audit_repo,违反持锁 await 慢操作禁令)。 + // 第一步:锁内(async block 包裹,出 block 自动 drop guard)反向扫描,定位旧 tool_call_id 与对应 tool_result content + let cached: Option<(String, String)> = (async { + let session = session_arc.lock().await; + // F-260616-09 B 批2:读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv, + // 故 conv_read 必命中;防御性 None 时返 None(无缓存命中,走原审批流程)。 + let Some(conv) = session.conv_read(conv_id) else { return None }; + // ContextManager::iter 返回 impl Iterator(非 DoubleEnded),collect 成 Vec 再反向遍历。 + // 单对话消息量小(百级),collect 开销可忽略。 + let msgs: Vec<&ChatMessage> = conv.messages.iter().collect(); - // 1) 反向扫描 assistant tool_calls,找最近一条同名同参的 High 工具调用 → 拿到旧 tool_call_id - // 反向:循环是「最近一次超时→重试」,命中通常是末尾附近,反向先停省全扫。 - let mut prev_tool_call_id: Option = None; - for msg in msgs.iter().rev() { - if !matches!(msg.role, MessageRole::Assistant) { - continue; - } - let Some(tcs) = msg.tool_calls.as_ref() else { continue }; - for tc in tcs { - if tc.function.name != tool_name { + // 1) 反向扫描 assistant tool_calls,找最近一条同名同参的 High 工具调用 → 拿到旧 tool_call_id + // 反向:循环是「最近一次超时→重试」,命中通常是末尾附近,反向先停省全扫。 + let mut prev_tool_call_id: Option = None; + for msg in msgs.iter().rev() { + if !matches!(msg.role, MessageRole::Assistant) { continue; } - // 旧调用的 args 是流式拼接的 JSON 字符串,解析失败跳过(不误判为命中) - let Ok(old_args) = serde_json::from_str::(&tc.function.arguments) else { - continue; - }; - if canonical_args_key(&old_args) == new_args_key { - prev_tool_call_id = Some(tc.id.clone()); + let Some(tcs) = msg.tool_calls.as_ref() else { continue }; + for tc in tcs { + if tc.function.name != tool_name { + continue; + } + // 旧调用的 args 是流式拼接的 JSON 字符串,解析失败跳过(不误判为命中) + let Ok(old_args) = serde_json::from_str::(&tc.function.arguments) else { + continue; + }; + if canonical_args_key(&old_args) == new_args_key { + prev_tool_call_id = Some(tc.id.clone()); + break; + } + } + if prev_tool_call_id.is_some() { break; } } - if prev_tool_call_id.is_some() { - break; - } - } - // 2) 用旧 tool_call_id 找对应 tool_result。注意:审批拒绝/超时失败也属「已落定」, - // 其 tool_result 内容同样回传(LLM 看到原反馈自行决定,不再逼用户二次审批)。 - // pending 占位(「需要用户审批,等待确认」)不命中——仍在审批中,走原流程。 - let old_id = prev_tool_call_id?; - for msg in msgs.iter().rev() { - if !matches!(msg.role, MessageRole::Tool) { - continue; + // 2) 用旧 tool_call_id 找对应 tool_result。注意:审批拒绝/超时失败也属「已落定」, + // 其 tool_result 内容同样回传(LLM 看到原反馈自行决定,不再逼用户二次审批)。 + // pending 占位(「需要用户审批,等待确认」)不命中——仍在审批中,走原流程。 + let Some(old_id) = prev_tool_call_id else { return None }; + for msg in msgs.iter().rev() { + if !matches!(msg.role, MessageRole::Tool) { + continue; + } + if msg.tool_call_id.as_deref() != Some(old_id.as_str()) { + continue; + } + // 命中旧 tool_result:排除 pending 占位(基础文本或带 __PENDING__:tc_id 标记) + // 阶段2:占位带标记后不再精确等于基础文本,改用 is_pending_placeholder 匹配。 + // 加固(子串误伤):`__PENDING__` 标记是 audit/cache.rs 占位模板独占信号(权威判定); + // 老占位分支已收紧为精确全文等值(非 starts_with 前缀),杜绝用户真实 tool_result 内容 + // 恰以"需要用户审批..."开头被误判占位致去重误吞(详见 context_helpers::is_pending_placeholder)。 + if is_pending_placeholder(&msg.content) { + return None; + } + return Some((old_id, msg.content.clone())); } - if msg.tool_call_id.as_deref() != Some(old_id.as_str()) { - continue; - } - // 命中旧 tool_result:排除 pending 占位(基础文本或带 __PENDING__:tc_id 标记) - // 阶段2:占位带标记后不再精确等于基础文本,改用 is_pending_placeholder 匹配。 - // 加固(子串误伤):`__PENDING__` 标记是 audit/cache.rs 占位模板独占信号(权威判定); - // 老占位分支已收紧为精确全文等值(非 starts_with 前缀),杜绝用户真实 tool_result 内容 - // 恰以"需要用户审批..."开头被误判占位致去重误吞(详见 context_helpers::is_pending_placeholder)。 - if is_pending_placeholder(&msg.content) { - return None; - } - // SW-260618-16: 查审计表拿缓存来源真实 status(completed/rejected/failed),透传给 - // audit_tool_call 而非固定 completed(审计语义与结果内容一致,防"rejected/failed 结果 - // 记 completed"误导安全追溯)。审计记录缺失/查询失败 fallback completed(不阻塞去重,降级原行为)。 - let status = audit_repo - .find_by_tool_call_id(old_id.as_str()) - .await - .ok() - .and_then(|opt| opt.map(|rec| rec.status)) - .unwrap_or_else(|| "completed".to_string()); - return Some((msg.content.clone(), status)); - } - None + None + }).await; + // 锁外:查审计表拿缓存来源真实 status(completed/rejected/failed),透传给 + // audit_tool_call 而非固定 completed(审计语义与结果内容一致,防"rejected/failed 结果 + // 记 completed"误导安全追溯)。审计记录缺失/查询失败 fallback completed(不阻塞去重,降级原行为)。 + let (old_id, content) = cached?; + let status = audit_repo + .find_by_tool_call_id(old_id.as_str()) + .await + .ok() + .and_then(|opt| opt.map(|rec| rec.status)) + .unwrap_or_else(|| "completed".to_string()); + Some((content, status)) } /// 把 JSON args 规范化为可比字符串:对象键按字典序排序后序列化, @@ -156,6 +167,117 @@ fn canonical_args_key(args: &serde_json::Value) -> String { serde_json::to_string(&v).unwrap_or_default() } +/// BUG-2026-07-07/P0-2:只读幂等工具结果缓存(治 LLM 死循环重调)。 +/// +/// **根因链(实测 9357c27c)**:LLM 无"已调用过"记忆,对同参只读工具反复触发: +/// - `read_file(application.xml)` 读了 17 次(两次返回完全相同的 8457c 全文) +/// - `search_files(pattern='.java')` 连调 13 次(每次返回 items=0/has_more=True 几乎一致) +/// 每次都真执行 + 全文回灌 tool_result,是 prompt 64 万的直接元凶。 +/// +/// **治本**:对幂等只读工具,扫描会话历史,若发现**已成功完成**的同类同参调用, +/// 返回其 tool_result 内容,调用方把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM, +/// 跳过真实执行(也跳过 emit Started/Completed——直接走缓存回填路径)。 +/// +/// **安全边界**(对照 find_cached_high_risk_result 严格收紧): +/// - 仅匹配白名单只读幂等工具(READONLY_CACHE_TOOLS):read_file/list_directory/ +/// search_files/grep/read_symbol。写工具(write_file/patch_file/run_command 等) +/// 永不缓存,避免误伤。 +/// - 仅匹配 **status=completed** 的成功结果(失败/拒绝不缓存,可能重试成功)。 +/// - 仅匹配 **非占位** 结果(排除 pending_placeholder 占位)。 +/// - args 走 JSON 规范化比较(键序无关),与 find_cached_high_risk_result 一致。 +/// - 返回 None 表示无缓存命中(走原执行流程)。 +/// +/// 与 find_cached_high_risk_result 区别:后者治"超时→重试→重新审批"循环( High risk), +/// 本函数治"LLM 失忆→同参重调"死循环( Low risk 只读),问题形态相似但触发原因不同。 +pub(crate) async fn find_cached_readonly_result( + session_arc: &Arc>, + conv_id: &str, + audit_repo: &AiToolExecutionRepo, + tool_name: &str, + args: &serde_json::Value, +) -> Option { + use df_ai::provider::MessageRole; + + // 白名单:只读幂等工具才缓存。写工具/有副作用工具永不缓存。 + if !READONLY_CACHE_TOOLS.contains(&tool_name) { + return None; + } + + let new_args_key = canonical_args_key(args); + + // BUG-260624-03/P0 重构:短 lock 段读 messages + 锁外 await DB 查 status(原代码持锁 await) + let cached: Option<(String, String)> = (async { + let session = session_arc.lock().await; + let Some(conv) = session.conv_read(conv_id) else { return None }; + let msgs: Vec<&ChatMessage> = conv.messages.iter().collect(); + + // 1) 反向扫描 assistant tool_calls,找最近一条同名同参调用 → 拿到旧 tool_call_id + let mut prev_tool_call_id: Option = None; + for msg in msgs.iter().rev() { + if !matches!(msg.role, MessageRole::Assistant) { + continue; + } + let Some(tcs) = msg.tool_calls.as_ref() else { continue }; + for tc in tcs { + if tc.function.name != tool_name { + continue; + } + let Ok(old_args) = serde_json::from_str::(&tc.function.arguments) else { + continue; + }; + if canonical_args_key(&old_args) == new_args_key { + prev_tool_call_id = Some(tc.id.clone()); + break; + } + } + if prev_tool_call_id.is_some() { + break; + } + } + + // 2) 用旧 tool_call_id 找对应 tool_result + let Some(old_id) = prev_tool_call_id else { return None }; + for msg in msgs.iter().rev() { + if !matches!(msg.role, MessageRole::Tool) { + continue; + } + if msg.tool_call_id.as_deref() != Some(old_id.as_str()) { + continue; + } + // 排除 pending 占位(仍在审批中,未真执行) + if is_pending_placeholder(&msg.content) { + return None; + } + return Some((old_id, msg.content.clone())); + } + None + }).await; + // 锁外:查审计表确认是 completed 成功结果(失败/拒绝不缓存,给 LLM 重试机会) + let (old_id, content) = cached?; + let status = audit_repo + .find_by_tool_call_id(old_id.as_str()) + .await + .ok() + .and_then(|opt| opt.map(|rec| rec.status)) + .unwrap_or_else(|| "completed".to_string()); + if status != "completed" { + return None; + } + Some(content) +} + +/// 可缓存的只读幂等工具白名单。 +/// 仅含纯读取/查询类工具:同参多次调用结果一致,缓存命中安全。 +/// 写工具/有副作用工具(write_file/patch_file/run_command/create_*/update_*/delete_* 等) +/// 永不进此白名单——它们每次执行都可能改变状态,缓存会掩盖变化。 +const READONLY_CACHE_TOOLS: &[&str] = &[ + "read_file", // 读文件内容(同 path+offset+limit+search 参数 → 同结果) + "read_symbol", // AST 符号读取(同 path+symbol+full → 同结果) + "list_directory", // 列目录(同 path+recursive+max_depth → 同结果) + "search_files", // 搜文件名(同 path+pattern+offset+limit → 同结果) + "grep", // 搜文件内容(同 path+pattern+mode → 同结果) +]; + /// 递归对 JSON 对象的键做字典序排序(就地),数组成员也递归排序。 fn sort_object_keys(v: &mut serde_json::Value) { match v { diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index 5956740..ad5e30b 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -4,6 +4,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; +use tokio::sync::Mutex; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_ai::provider::ChatMessage; @@ -62,10 +63,10 @@ pub use restore::restore_pending_approvals; mod finalize; pub(crate) use finalize::audit_finalize; -// cache(audit/cache.rs):F-260616-05 高危工具去重缓存。 -// 第三批从本文件抽离,行为零变更。 +// cache(audit/cache.rs):F-260616-05 高危工具去重缓存 + BUG-2026-07-07/P0-2 只读工具缓存。 +// 第三批从本文件抽离,行为零变更(P0-2 为新增能力)。 mod cache; -pub(super) use cache::pending_placeholder_for; +pub(super) use cache::{find_cached_readonly_result, pending_placeholder_for}; // data_change(audit/data_change.rs):AR-11 数据变更联动刷新。 // 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见 @@ -136,11 +137,25 @@ async fn execute_with_heartbeat( }); // RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。 let _guard = HeartbeatGuard { stop, handle: heartbeat }; - tools.execute(name, args).await + // BUG-2026-07-19: tools.execute 无 timeout 时,卡死工具(run_command 长命令/read_file 大文件/同步 + // 阻塞工具)永久挂起 → process_tool_calls 持 session lock 永久 → guard.reset 等 lock → AiCompleted + // 永不发 → 前端"回答完卡住/超时清空"。60s timeout 兜底:超时返错误 tool_result,锁释放,loop 续跑。 + // 心跳 30s 续命前端 watchdog,60s timeout 覆盖绝大多数工具(run_command 已自带 10s 子超时)。 + match tokio::time::timeout(Duration::from_secs(60), tools.execute(name, args)).await { + Ok(result) => result, + Err(_elapsed) => { + tracing::error!( + conv_id = %conv_id, + tool = %name, + "[ai] 工具执行超时(60s),返回错误 tool_result(防 process_tool_calls 持 session lock 永久卡死)" + ); + Err(anyhow::anyhow!("工具执行超时(60s),已中止(防死锁)")) + } + } } pub(crate) async fn process_tool_calls( - session: &mut AiSession, + session_arc: &Arc>, tool_calls_acc: HashMap, tools_arc: &Arc, db: &Arc, @@ -164,9 +179,33 @@ pub(crate) async fn process_tool_calls( // push 到 per_conv.messages(audit/mod.rs:882),此处取末条 assistant id 作为本轮工具 // 调用所属的溯源 message_id,贯穿所有 audit_tool_call 写入。None 表示无 assistant 消息 // (异常路径/老数据无 id),audit 落 message_id=None,展示侧兼容。 - let current_message_id: Option = session - .conv_read(conv_id) - .and_then(|c| c.messages.last_assistant_message_id()); + // + // BUG-260624-03/P0 重构:短 lock 段读 current_message_id(纯读,无 await),clone 出来后续传参。 + // 整个 process_tool_calls 不再要求调用方持锁,内部所有 session.xxx 访问均短 lock 段, + // 慢操作(execute_with_heartbeat/audit_tool_call/detect_retry_count/DB 查)全在锁外 await。 + // BUG-2026-07-19: current_message_id 读加 200ms timeout(防 session lock 竞争卡死致 + // process_tool_calls 进不去 emit AiToolCallStarted → 前端工具卡片不呈现 + 45s 看门狗超时)。 + // 超时用 None(溯源降级,非致命)。治本:消除 session lock 长持有(save clone 段已优化)。 + let current_message_id: Option = match tokio::time::timeout( + std::time::Duration::from_millis(200), + async { + let __lock_t = std::time::Instant::now(); + let session = session_arc.lock().await; + let __id = session.conv_read(conv_id) + .and_then(|c| c.messages.last_assistant_message_id()); + let __hold = __lock_t.elapsed(); + if __hold > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] process_tool_calls:192 持锁 {:?} (含 lock 等待)", __hold); + } + __id + }, + ).await { + Ok(id) => id, + Err(_) => { + tracing::warn!(conv_id = %conv_id, "[ai] process_tool_calls current_message_id lock 200ms 超时,溯源降级 None(session lock 竞争)"); + None + } + }; let current_message_id = current_message_id.as_deref(); // 解析 args + 批量发 Started(前端骨架按原始 index 顺序展示) @@ -218,8 +257,22 @@ pub(crate) async fn process_tool_calls( // Phase C: Denied 路径 → 硬拒(push 错误 tool_result + emit Completed),不挂起 loop。 // 工具返 Err 让 LLM 知路径被禁,自行调整;loop 继续下一轮(不暂停)。 for (draft, reason) in path_denied { - let err_msg = format!("路径授权拒绝: {}", reason); - session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_msg)); + // BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议 + let err_msg = serde_json::json!({ + "status": "rejected", + "reason": "path_blacklist", + "message": format!("路径授权拒绝: {}", reason) + }).to_string(); + // 短 lock 段:push tool_result(纯写,无 await) + { + let __lock_t = std::time::Instant::now(); + let mut session = session_arc.lock().await; + session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_msg)); + let __hold = __lock_t.elapsed(); + if __hold > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] process_tool_calls:262 持锁 {:?} (含 lock 等待)", __hold); + } + } // L3 emit 双写:路径黑名单拒绝 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), @@ -240,6 +293,9 @@ pub(crate) async fn process_tool_calls( // Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。 // 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。 // pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。 + // + // BUG-260624-03/P0 重构:detect_retry_count(DB 读)/emit/audit_tool_call(DB 写)均在锁外, + // 仅 push tool_result + pending_approvals.insert 两处纯写改短 lock 段。 for (draft, args, req) in path_auth_pending { pending_count += 1; // L1 补丁:req.dirs 含所有未授权父目录;emit 用首个作主展示目录(前端弹窗主显)。 @@ -248,14 +304,23 @@ pub(crate) async fn process_tool_calls( .map(|d| d.to_string_lossy().to_string()) .unwrap_or_default(); let path_str = req.raw_paths.first().cloned().unwrap_or_default(); - // 阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。 + // 锁外:阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。 let retry_count = detect_retry_count(&audit_repo, &draft.id).await; if retry_count >= 1 { let skip_msg = format!( "已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)", draft.id ); - session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg)); + // 短 lock 段:push tool_result(纯写) + { + let __lock_t = std::time::Instant::now(); + let mut session = session_arc.lock().await; + session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg)); + let __hold = __lock_t.elapsed(); + if __hold > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] process_tool_calls:305 持锁 {:?} (含 lock 等待)", __hold); + } + } let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(skip_msg.clone()), @@ -271,22 +336,31 @@ pub(crate) async fn process_tool_calls( ).await; continue; } - session.pending_approvals.insert( - draft.id.clone(), - PendingApproval { - tool_call_id: draft.id.clone(), - tool_name: draft.name.clone(), - arguments: args.clone(), - conversation_id: Some(conv_id.to_string()), - recovered: false, - // 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。 - kind: ApprovalKind::Path(req), - retry_count, - created_at: Some(std::time::SystemTime::now()), - }, - ); - // 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。 - session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id))); + // 短 lock 段:pending_approvals.insert + 占位 tool_result push(两处纯写,无 await) + { + let __lock_t = std::time::Instant::now(); + let mut session = session_arc.lock().await; + session.pending_approvals.insert( + draft.id.clone(), + PendingApproval { + tool_call_id: draft.id.clone(), + tool_name: draft.name.clone(), + arguments: args.clone(), + conversation_id: Some(conv_id.to_string()), + recovered: false, + // 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。 + kind: ApprovalKind::Path(req), + retry_count, + created_at: Some(std::time::SystemTime::now()), + }, + ); + // 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。 + session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id))); + let __hold = __lock_t.elapsed(); + if __hold > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] process_tool_calls:325 持锁 {:?} (含 lock 等待)", __hold); + } + } tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)"); let ev = AiChatEvent::AiDirAuthRequired { id: draft.id.clone(), @@ -351,17 +425,17 @@ pub(crate) async fn process_tool_calls( let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); handle_approval_tool( draft, args, risk_level, &auto_exec_mode, - session, conv_id, tools_arc, &audit_repo, + session_arc, conv_id, tools_arc, &audit_repo, app_handle, db, &mut low_risk, &mut trust_hits, &mut pending_count, current_message_id, ).await; } - // AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,非"锁已释放"—— - // session 锁仍由调用方 agentic/mod.rs 持有至 process_tool_calls 返回,CR-53 审查纠正原"移锁外"误述), - // push tool_result / audit 在 join_all 后串行回填(持锁)。对齐 Low risk 并行模式。 + // AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session, + // BUG-260624-03/P0 重构后调用方不再持锁,本段所有 await 完全在锁外), + // push tool_result / audit 在 join_all 后串行回填(短 lock push + 锁外 audit)。对齐 Low risk 并行模式。 // CR-51 修:原 inline .await execute 串行执行每个工具(阻塞期间锁被持有,run_command 慢命令 - // 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间(锁持有时长不变,并行化降阻塞)。 + // 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间。P0 重构后锁外并行,根本消除阻塞。 // join_all 保序——结果顺序 = trust_hits 输入顺序 = tc_list 原始 index 顺序,不额外 sort if !trust_hits.is_empty() { let results: Vec<(ToolCallDraft, RiskLevel, Result)> = @@ -403,23 +477,68 @@ pub(crate) async fn process_tool_calls( } })).await; - // 串行回填 tool_result + 审计(持 session 锁) + // 串行回填 tool_result + 审计(短 lock push + 锁外 audit) for (draft, risk_level, outcome) in results { let (status, content) = match outcome { Ok(c) => ("completed", c), Err(c) => ("failed", c), }; - // F-260616-09 B 批2:写 per_conv.messages。 - session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone())); - // 审计:trust 放行仍记一条(decided_by=auto_trust),留痕可追溯 + // 短 lock 段:push tool_result(纯写,无 await) + { + let mut session = session_arc.lock().await; + session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone())); + } + // 锁外:审计(trust 放行仍记一条,decided_by=auto_trust),留痕可追溯 audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust"), current_message_id).await; } } // Low 风险并行执行:execute + 即时 emit 在闭包内(不持 session 锁), - // push tool_result / audit 在 join_all 后串行回填(持锁,与 Med/High 占位拼接)。 + // push tool_result / audit 在 join_all 后串行回填(短 lock push + 锁外 audit,与 Med/High 占位拼接)。 // join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort if !low_risk.is_empty() { + // BUG-2026-07-07/P0-2:只读幂等工具去重缓存(治 LLM 死循环重调)。 + // 实测 9357c27c 会话:LLM 对 read_file(application.xml) 连调 17 次、search_files 连调 13 次, + // 每次返回几乎相同结果却反复重调,是 prompt 64 万的直接元凶。此处先查会话内是否已对 + // 同参只读工具成功执行过,命中则直接回填缓存结果跳过真执行,断 LLM 失忆死循环。 + // 安全边界见 find_cached_readonly_result 文档(仅白名单只读工具 + 仅 completed 成功结果)。 + // + // BUG-260624-03/P0 重构:find_cached_readonly_result 内部短 lock + 锁外 DB 查,本段不持锁。 + let mut low_risk_uncached: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::with_capacity(low_risk.len()); + for (draft, args, risk_level) in low_risk { + let cached = find_cached_readonly_result(session_arc, conv_id, &audit_repo, &draft.name, &args).await; + if let Some(cached_content) = cached { + // 缓存命中:直接 push tool_result + 审计(decided_by=cache_hit 标记缓存来源), + // 不走真执行 + 不重emit Started/Completed(避免误导前端工具又执行了一次)。 + // emit Completed 携带缓存结果供前端折叠卡片展示(与 find_cached_high_risk_result 一致)。 + let ev = AiChatEvent::AiToolCallCompleted { + id: draft.id.clone(), + result: serde_json::Value::String(cached_content.clone()), + conversation_id: Some(conv_id.to_string()), + }; + let _ = app_handle.emit("ai-chat-event", ev.clone()); + let _ = app_handle.state::().ai_event_bus.publish_event(ev); + // 短 lock 段:push tool_result(纯写,无 await) + { + let mut session = session_arc.lock().await; + session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached_content)); + } + audit_tool_call( + &audit_repo, conv_id, &draft.id, &draft.name, &draft.args, + "completed", risk_level, Some(cached_content), Some("cache_hit"), + current_message_id, + ).await; + tracing::info!( + conv_id = %conv_id, + tool = %draft.name, + tc_id = %draft.id, + "[ai] 只读工具缓存命中,跳过真执行(治 LLM 死循环重调)" + ); + } else { + low_risk_uncached.push((draft, args, risk_level)); + } + } + let low_risk = low_risk_uncached; // 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。 let results: Vec<(ToolCallDraft, serde_json::Value, RiskLevel, Result)> = futures::future::join_all(low_risk.into_iter().map(|(draft, args, risk_level)| { @@ -464,7 +583,7 @@ pub(crate) async fn process_tool_calls( } })).await; - // 串行回填 tool_result + 审计(持 session 锁) + // 串行回填 tool_result + 审计(短 lock push + 锁外 audit) for (draft, raw_result, risk_level, outcome) in results { let (status, content) = match outcome { Ok(c) => ("completed", c), @@ -477,8 +596,11 @@ pub(crate) async fn process_tool_calls( let args_val = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Null); maybe_fill_idea_source(db, &draft.name, &args_val, &raw_result, current_message_id).await; } - // F-260616-09 B 批2:写 per_conv.messages。 - session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone())); + // 短 lock 段:写 per_conv.messages(纯写,无 await) + { + let mut session = session_arc.lock().await; + session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone())); + } // F-#97 审计留痕:low_risk 向量中 risk_level != Low 即 mode 放行(Med 仅 medium/all、High 仅 all 才进)。 // decided_by 区分接管来源,审计表可追溯 all 模式执行了多少高危命令(治 securityReview blocker: // 原硬编码 RiskLevel::Low 致 Med/High 接管操作全标 Low/auto 失真,等于没记)。 @@ -492,12 +614,18 @@ pub(crate) async fn process_tool_calls( } // 阶段3a:单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。 - let (path_count, risk_count) = session.pending_approvals.values() - .fold((0usize, 0usize), |(p, r), a| match a.kind { - ApprovalKind::Path(_) => (p + 1, r), - ApprovalKind::Risk { .. } => (p, r + 1), - }); - tracing::debug!(target: "ai_dirauth", pending_count, pending_approvals_len = session.pending_approvals.len(), path_count, risk_count, "process_tool_calls 返回(路径/风险挂起分布)"); + // 短 lock 段:读 pending_approvals 统计(纯读,无 await) + let (path_count, risk_count, pending_approvals_len) = { + let session = session_arc.lock().await; + let len = session.pending_approvals.len(); + let (p, r) = session.pending_approvals.values() + .fold((0usize, 0usize), |(p, r), a| match a.kind { + ApprovalKind::Path(_) => (p + 1, r), + ApprovalKind::Risk { .. } => (p, r + 1), + }); + (p, r, len) + }; + tracing::debug!(target: "ai_dirauth", pending_count, pending_approvals_len, path_count, risk_count, "process_tool_calls 返回(路径/风险挂起分布)"); pending_count } diff --git a/src-tauri/src/commands/ai/augmentation/resolvers.rs b/src-tauri/src/commands/ai/augmentation/resolvers.rs index 0b30c61..90a8b38 100644 --- a/src-tauri/src/commands/ai/augmentation/resolvers.rs +++ b/src-tauri/src/commands/ai/augmentation/resolvers.rs @@ -304,12 +304,15 @@ impl MentionResolver for SkillResolver { } }; // 取剥 frontmatter 正文(注入用);None = 缓存未命中或文件读失败 - let body = read_skill_content_stripped(&name).ok_or_else(|| ResolveError::NotFound { - kind: "skill".to_string(), - ref_id: name.clone(), - })?; + let body = read_skill_content_stripped(name.clone()) + .await + .ok_or_else(|| ResolveError::NotFound { + kind: "skill".to_string(), + ref_id: name.clone(), + })?; // source 从缓存取 SkillInfo.source;失败(缓存不一致)默认 "skill" let source = crate::commands::ai::skills::skills_cached() + .await .into_iter() .find(|s| s.name == name) .map(|s| s.source) diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 36858be..6644443 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -24,9 +24,11 @@ use crate::state::AppState; use crate::commands::{err_str, now_millis}; // chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。 -use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS}; -// G1 目标钉扎:GOAL_PIN_ENABLED/MAX_GOALS 已迁移至 agentic 侧使用,chat 不再需要(extract_pinned_goal 纯函数只依赖 GOAL_MAX_CHARS 截断) -// 双轨收口批1:读侧入口拦截用 ConvState(can_accept_request 的 unwrap_or 兜底初值)。 +use super::super::agentic::{run_agentic_loop, try_continue_agent_loop}; +#[cfg(test)] +use super::super::agentic::GOAL_MAX_CHARS; +// ConvState 枚举变体引用(Idle 等),用于 B-Phase2 写侧 conv_states.transition 调用。 +// 读侧拦截(can_accept_request)走 state.conv_states 方法,不直接引用 ConvState 类型。 use super::super::agentic::conv_state::ConvState; use super::super::audit::{audit_finalize, emit_data_changed}; use super::super::augmentation::build_augmentation_segment; @@ -73,10 +75,8 @@ pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSessio } } -/// 去噪助手:从文本中 strip `[kind:...]` mention 标签(纯函数,无 IO)。 -/// -/// 原为 G1 目标钉扎的提取函数(2026-07-03 已改为工具调用推理),现保留供测试用例使用。 -#[allow(dead_code)] +/// 原为 G1 目标钉扎的提取函数(2026-07-03 已改为工具调用推理),现仅测试用例使用。 +#[cfg(test)] pub(crate) fn extract_pinned_goal(text: &str) -> String { // strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑, // 单一真相源:KINDS 表与 intent.rs 一致)。全文扫描,非仅行首。 @@ -251,15 +251,12 @@ pub async fn ai_regenerate( // 原子占用 generating + 弹出末尾 AI 回复(保留 user 消息) // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验 // (真并发下后台 conv 也应可重新生成);per_conv 唯一真相源,删顶层双写。 + // B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,不占 session lock)。 + if !state.conv_states.can_accept_request(&conversation_id) { + return Err("AI 正在生成中,请等待完成".to_string()); + } { let mut session = state.ai_session.lock().await; - // 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。 - // 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 仅拦 - // Generating/Compressed(漏拦 Stopping 态),can_accept_request 更严谨(防停止中接新请求竞态)。 - let cs = session.conv_read(&conversation_id).map(|c| c.conv_state).unwrap_or(ConvState::Idle); - if !cs.can_accept_request() { - return Err("AI 正在生成中,请等待完成".to_string()); - } let conv = session.conv(&conversation_id); // 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口 ConvState→Generating // 迁移设置(此处不再手动赋值)。stop_flag/iteration/model_override 仍是 per_conv 独立字段。 @@ -344,18 +341,21 @@ pub async fn ai_is_generating( // conv_id 为空(前端未传/分离窗口无活跃)时 fallback active conv 兼容旧调用方。 conversation_id: Option, ) -> Result { - let session = state.ai_session.lock().await; - let target = conversation_id.filter(|s| !s.is_empty()) - .or_else(|| session.active_conversation_id.clone()) - .unwrap_or_default(); + // B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争)。target 解析仍需 active_conversation_id + // (单值读),仅此处轻量 lock 取 active,ConvState 读完全脱离 session lock。 + let target = { + let session = state.ai_session.lock().await; + conversation_id.filter(|s| !s.is_empty()) + .or_else(|| session.active_conversation_id.clone()) + .unwrap_or_default() + }; if target.is_empty() { - // 无目标 conv 且无 active:无任何 conv 在跑,返 false(批4 顶层 generating 已退役)。 + // 无目标 conv 且无 active:无任何 conv 在跑,返 false。 return Ok(false); } - // L2 读侧(双轨收口批1):统一读 conv_state.is_active(),删除 CONV_STATE_ENABLED off 回退分支 - // (enum 单路径)。is_active() 拦 Generating+Compressed(压缩期间 loop 仍活跃),语义与 - // 原 c.generating 一致(此读点用于判断「是否在生成中」,应保留拦 Compressed)。 - Ok(session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false)) + // L2 读侧:统一读 conv_state.is_active()(Generating/Compressed)。 + // B-Phase2:切 conv_states(无锁),语义等价(同一 ConvState 值)。 + Ok(state.conv_states.is_active(&target)) } /// 发送消息并获取流式 AI 响应 @@ -416,11 +416,10 @@ pub async fn ai_chat_send( session.active_conv_created_at = Some(now_millis()); } } - // 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。 - // 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 漏拦 Stopping 态, + // 入口拦截(双轨收口批1):拦 Stopping/Generating/Compressed,放行 Idle+Error。 + // B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争)。原 c.generating 漏拦 Stopping 态, // can_accept_request 更严谨。仅查目标 conv(真并发下各 conv 独立)。 - let cs = session.conv_read(&target).map(|c| c.conv_state).unwrap_or(ConvState::Idle); - if !cs.can_accept_request() { + if !state.conv_states.can_accept_request(&target) { return Err("AI 正在生成中,请等待完成".to_string()); } let conv = session.conv(&target); @@ -455,11 +454,6 @@ pub async fn ai_chat_send( // DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id), // 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。 let user_msg_id = conv.messages.last_user_message_id(); - // G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(2026-07-03 改), - // 从 LLM 本轮调用的工具名+路径推理目标,不再从用户消息规则提取。 - // 用户发送的短指令(如「继续」「确认」)不会产生低价值目标。 - // 此处不再从原始用户消息提取。 - // GOAL_PIN_ENABLED=false → 跳过整块。 (target, user_msg_id) }; @@ -515,6 +509,7 @@ pub async fn ai_approve( tool_call_id: String, approved: bool, ) -> Result { + let __lock_t534 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; let approval = match session.pending_approvals.remove(&tool_call_id) { @@ -560,7 +555,14 @@ pub async fn ai_approve( // 无 conversation_id 的无主审批(R-9 异常数据)无 per_conv 可写,拒绝结果不入 messages // (审计仍记 rejected),避免写错 conv。 if let Some(ref cid) = approval.conversation_id { - session.conv(cid).messages.replace_tool_result_content(&tool_call_id, "用户拒绝了此操作"); + // BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议 + // (部分 provider 严格解析 tool role content,裸文本会触发协议错误)。 + let reject_payload = serde_json::json!({ + "status": "rejected", + "reason": "user_denied", + "message": "用户拒绝了此操作" + }).to_string(); + session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &reject_payload); } let conv_id = approval.conversation_id.clone(); let ev = AiChatEvent::AiApprovalResult { @@ -584,11 +586,17 @@ pub async fn ai_approve( // 作 start_iteration,防多次审批反复跑满 max_iterations 致 token 失控。 // F-09 B 批4:per_conv 唯一真相源(conv_id 来源 approval.conversation_id);无 conv_id 时 0。 let start_iter = { + let __lock_t610 = std::time::Instant::now(); let session = state.ai_session.lock().await; - conv_id.as_deref() + let v = conv_id.as_deref() .and_then(|c| session.conv_read(c)) .map(|c| c.iteration_used) - .unwrap_or(0) + .unwrap_or(0); + let __hold610 = __lock_t610.elapsed(); + if __hold610 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_approve:610 持锁 {:?} (含 lock 等待)", __hold610); + } + v }; // 所有待审批处理完毕后恢复 agentic 循环 // F-09 B 批2:传 conv_id(approval.conversation_id),try_continue 按 conv 过滤 pending/读 per_conv。 @@ -620,6 +628,10 @@ pub async fn ai_approve( ); } } + let __hold534 = __lock_t534.elapsed(); + if __hold534 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_approve:534 持锁 {:?} (含 lock 等待)", __hold534); + } drop(session); // 释放锁后再执行 // B-260617-01 决策 a(方案 a2 后端分支):run_workflow 特殊处理。 @@ -678,6 +690,7 @@ pub async fn ai_approve( // 重新获取锁,替换占位 tool_result 为真实结果(失败时为错误信息,LLM 据此决定下一步) // F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源 approval.conversation_id)。 // 无 conv_id 的无主审批不入 messages(审计仍记 completed/failed)。 + let __lock_t704 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; if let Some(ref cid) = conv_id { session.conv(cid).messages.replace_tool_result_content(&id, &result_val.to_string()); @@ -699,6 +712,10 @@ pub async fn ai_approve( let _ = app.emit("ai-chat-event", ev_approval.clone()); // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。 let _ = app.state::().ai_event_bus.publish_event(ev_approval); + let __hold704 = __lock_t704.elapsed(); + if __hold704 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_approve:704 持锁 {:?} (含 lock 等待)", __hold704); + } drop(session); // F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。 @@ -709,10 +726,16 @@ pub async fn ai_approve( // 仅 completed(成功)且 create_idea 才补;失败/其他工具 noop(helper 内判定,不污染本路径主流程)。 if audit_status == "completed" { let message_id = { + let __lock_t735 = std::time::Instant::now(); let session = state.ai_session.lock().await; - conv_id.as_deref().and_then(|cid| { + let v = conv_id.as_deref().and_then(|cid| { session.conv_read(cid).and_then(|c| c.messages.last_assistant_message_id()) - }) + }); + let __hold735 = __lock_t735.elapsed(); + if __hold735 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_approve:735 持锁 {:?} (含 lock 等待)", __hold735); + } + v }; super::super::audit::maybe_fill_idea_source( &state.db, @@ -735,8 +758,14 @@ pub async fn ai_approve( // F-09 B 批4:per_conv 唯一真相源;无 conv_id 时 start_iter=0。 let cont_conv_id = conv_id.clone().unwrap_or_default(); let start_iter = { + let __lock_t761 = std::time::Instant::now(); let session = state.ai_session.lock().await; - session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0) + let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0); + let __hold761 = __lock_t761.elapsed(); + if __hold761 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_approve:761 持锁 {:?} (含 lock 等待)", __hold761); + } + v }; // 所有待审批处理完毕后恢复 agentic 循环(recovered 无 live loop,try_continue 因 generating=false 自然不续) try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await; @@ -838,11 +867,17 @@ pub async fn ai_authorize_dir( ); // 取 pending(阶段3a 单真相源合并:从 pending_approvals remove;kind 校验为 Path)。 let approval = { + let __lock_t864 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; - match session.pending_approvals.remove(&tool_call_id) { + let v = match session.pending_approvals.remove(&tool_call_id) { Some(a) => a, None => return Err(format!("未找到路径授权挂起: {}", tool_call_id)), + }; + let __hold864 = __lock_t864.elapsed(); + if __hold864 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_authorize_dir:864 持锁 {:?} (含 lock 等待)", __hold864); } + v }; // 阶段3a 决策层分离:ai_authorize_dir 只消费 kind==Path(路径授权挂起,once/always/deny)。 // 提取 PathAuthRequest(kind 下沉原 path_auth 字段),非 Path(误调普通 RiskLevel 审批)→ Err。 @@ -852,8 +887,13 @@ pub async fn ai_authorize_dir( ApprovalKind::Path(req) => req, ApprovalKind::Risk { .. } => { // 误调:re-insert 回单表保持挂起,返 Err(前端据 Err 提示改调 ai_approve)。 + let __lock_t878 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; session.pending_approvals.insert(tool_call_id.clone(), approval); + let __hold878 = __lock_t878.elapsed(); + if __hold878 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_authorize_dir:878 持锁 {:?} (含 lock 等待)", __hold878); + } return Err(format!( "tool_call_id={} 非路径授权挂起(普通审批请用 ai_approve)", tool_call_id @@ -870,12 +910,22 @@ pub async fn ai_authorize_dir( // ── "deny":拒绝路径授权 → 工具返 Err,恢复 loop ── if decision == "deny" { - let err_msg = "用户拒绝路径授权".to_string(); + // BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议 + let err_msg = serde_json::json!({ + "status": "rejected", + "reason": "path_auth_denied", + "message": "用户拒绝路径授权" + }).to_string(); { + let __lock_t903 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; if let Some(ref cid) = conv_id { session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &err_msg); } + let __hold903 = __lock_t903.elapsed(); + if __hold903 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_authorize_dir:903 持锁 {:?} (含 lock 等待)", __hold903); + } } let ev_completed = AiChatEvent::AiToolCallCompleted { id: tool_call_id.clone(), @@ -899,8 +949,14 @@ pub async fn ai_authorize_dir( } let cont_conv_id = conv_id.clone().unwrap_or_default(); let start_iter = { + let __lock_t930 = std::time::Instant::now(); let session = state.ai_session.lock().await; - session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0) + let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0); + let __hold930 = __lock_t930.elapsed(); + if __hold930 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_authorize_dir:930 持锁 {:?} (含 lock 等待)", __hold930); + } + v }; try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await; return Ok("rejected".to_string()); @@ -987,10 +1043,15 @@ pub async fn ai_authorize_dir( // 替换占位 tool_result 为真实结果 { + let __lock_t1018 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; if let Some(ref cid) = conv_id { session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &result_val.to_string()); } + let __hold1018 = __lock_t1018.elapsed(); + if __hold1018 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_authorize_dir:1018 持锁 {:?} (含 lock 等待)", __hold1018); + } } let ev_completed = AiChatEvent::AiToolCallCompleted { id: tool_call_id.clone(), @@ -1013,8 +1074,14 @@ pub async fn ai_authorize_dir( } let cont_conv_id = conv_id.clone().unwrap_or_default(); let start_iter = { + let __lock_t1044 = std::time::Instant::now(); let session = state.ai_session.lock().await; - session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0) + let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0); + let __hold1044 = __lock_t1044.elapsed(); + if __hold1044 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_authorize_dir:1044 持锁 {:?} (含 lock 等待)", __hold1044); + } + v }; try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await; Ok(audit_status.to_string()) @@ -1040,6 +1107,7 @@ pub async fn ai_pending_tool_calls( state: State<'_, AppState>, conv_id: String, ) -> Result, String> { + let __lock_t1071 = std::time::Instant::now(); let session = state.ai_session.lock().await; // 阶段3a 单真相源合并:单 pending_approvals 表按 conv_id 过滤,kind 字段透传给前端。 // 阶段4:返 kind 字段,前端 switchConversation 恢复 pendingApprovals 时按 kind 渲染 @@ -1058,12 +1126,17 @@ pub async fn ai_pending_tool_calls( }, }) .collect(); + let __hold1071 = __lock_t1071.elapsed(); + if __hold1071 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_pending_tool_calls:1071 持锁 {:?} (含 lock 等待)", __hold1071); + } Ok(list) } /// 清空对话历史 #[tauri::command] pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> { + let __lock_t1095 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; // 取活跃对话 id 后释放锁(避免持 session 锁调 DB) let active_id = session.active_conversation_id.clone(); @@ -1077,6 +1150,10 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> { } // 阶段3a 单真相源合并:单表 retain(口径不变:清本 conv 保留其他 conv,kind 不区分)。 session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != active_id.as_deref()); + let __hold1095 = __lock_t1095.elapsed(); + if __hold1095 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_chat_clear:1095 持锁 {:?} (含 lock 等待)", __hold1095); + } drop(session); // 真删 DB:清空该对话 messages(JSON 备份列同步清空 + 清零 token,保留对话壳),刷新不再恢复(AR-7) // F-260619-03 批次 B:同时清空 ai_messages 表(全删,delete_range min_seq=0 max=None) @@ -1114,6 +1191,7 @@ pub async fn ai_chat_clear_context( // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验 // (真并发下后台 conv 也应可分段);messages 操作走 per_conv。 let conv_id = { + let __lock_t1145 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; // 保护区:保留最近 PROTECT_COUNT 条 active。 const PROTECT_COUNT: usize = 6; @@ -1136,6 +1214,10 @@ pub async fn ai_chat_clear_context( t.message.status = Some(MessageStatus::ArchivedSegment); } } + let __hold1145 = __lock_t1145.elapsed(); + if __hold1145 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_chat_clear_context:1145 持锁 {:?} (含 lock 等待)", __hold1145); + } drop(session); conversation_id }; @@ -1174,6 +1256,7 @@ pub async fn ai_chat_compress_context( // 取 active 克隆 + compress_end(读不改,LLM 失败则消息状态完全不变) // F-260616-09 B 批2:messages 操作改 per_conv(conv_id 来源:IPC 参数 conversation_id,已与 active 校验)。 let (conv_id, active_msgs, compress_end) = { + let __lock_t1205 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; // F-260616-09 B 批4(决策 e):移除 active 一致性校验(真并发下后台 conv 也应可压缩)。 let conv = session.conv(&conversation_id); @@ -1214,13 +1297,22 @@ pub async fn ai_chat_compress_context( .map(|t| t.message.clone()) .collect(); let cid = session.active_conversation_id.clone().unwrap_or_else(|| conversation_id.clone()); + let __hold1205 = __lock_t1205.elapsed(); + if __hold1205 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_chat_compress_context:1205 持锁 {:?} (含 lock 等待)", __hold1205); + } (cid, active_msgs, protect_start) }; if active_msgs.is_empty() { // 保护区外无 active 消息(已全 compressed/archived_segment/truncated) → noop + let __lock_t1250 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; session.conv(&conv_id).messages.set_compressing(false); + let __hold1250 = __lock_t1250.elapsed(); + if __hold1250 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_chat_compress_context:1250 持锁 {:?} (含 lock 等待)", __hold1250); + } drop(session); // 治 Task#1:手动 IPC 降级(无 active 可压缩)用 AiManualCompressed(前端弹 toast)。 let ev = AiChatEvent::AiManualCompressed { @@ -1238,8 +1330,13 @@ pub async fn ai_chat_compress_context( let provider = match super::super::secret::build_provider_for(&provider_config) { Ok(p) => p, Err(e) => { + let __lock_t1269 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; session.conv(&conv_id).messages.set_compressing(false); + let __hold1269 = __lock_t1269.elapsed(); + if __hold1269 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_chat_compress_context:1269 持锁 {:?} (含 lock 等待)", __hold1269); + } drop(session); let ev = AiChatEvent::AiError { error: format!("压缩失败: {}", e), @@ -1264,8 +1361,13 @@ pub async fn ai_chat_compress_context( Ok(s) => s, Err(e) => { // LLM 失败:不阻塞,set_compressing(false),消息状态不变(未调 compress_old_messages) + let __lock_t1295 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; session.conv(&conv_id).messages.set_compressing(false); + let __hold1295 = __lock_t1295.elapsed(); + if __hold1295 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_chat_compress_context:1295 持锁 {:?} (含 lock 等待)", __hold1295); + } drop(session); let ev = AiChatEvent::AiError { error: format!("压缩失败: {}", e), @@ -1281,11 +1383,16 @@ pub async fn ai_chat_compress_context( // LLM 成功 → 标 compressed(扣 token) + 摘要插首位 { + let __lock_t1312 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; let conv = session.conv(&conv_id); let _compressed = conv.messages.compress_old_messages(compress_end); conv.messages.insert_at(0, ChatMessage::system(&summary)); conv.messages.set_compressing(false); + let __hold1312 = __lock_t1312.elapsed(); + if __hold1312 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_chat_compress_context:1312 持锁 {:?} (含 lock 等待)", __hold1312); + } } save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await; // 治 Task#1:手动 IPC 成功主路径用 AiManualCompressed(Task#1 期望保留 toast+刷新)。 @@ -1321,14 +1428,12 @@ pub async fn ai_chat_edit( // 原子占用 generating + 替换末条 user content + truncate 其后 // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验 // (真并发下后台 conv 也应可编辑);per_conv 唯一真相源,删顶层双写。 + // B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争)。 + if !state.conv_states.can_accept_request(&conversation_id) { + return Err("AI 正在生成中,请等待完成".to_string()); + } { let mut session = state.ai_session.lock().await; - // 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。 - // 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 漏拦 Stopping 态。 - let cs = session.conv_read(&conversation_id).map(|c| c.conv_state).unwrap_or(ConvState::Idle); - if !cs.can_accept_request() { - return Err("AI 正在生成中,请等待完成".to_string()); - } let conv = session.conv(&conversation_id); // ① 替换末条 active user 消息 content(无 active user → Err) if conv.messages.replace_last_active_user_content(&new_message).is_err() { @@ -1442,6 +1547,7 @@ pub async fn ai_chat_force_send( // 不再跨 conv 杀(旧实现清全局 generating + 全 clear pending_approvals,在真并发下会误杀其他 // 后台 conv 的 loop/审批)。pending_approvals 仅清目标 conv 的(retain),保留其他 conv 的。 let (old_conv_id, conv_id, _user_message_id, old_pinned_goals) = { + let __lock_t1473 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; // 目标 conv:入参优先 → active → 懒创建。 let target = conversation_id.clone().filter(|s| !s.is_empty()) @@ -1462,9 +1568,9 @@ pub async fn ai_chat_force_send( } } // ① 软停止复位目标 conv(与 ai_chat_stop 审批分支一致),emit AiCompleted 用旧 target 自身。 - // was_gen 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed), - // 用于判断「原是否在生成」以决定是否 emit AiCompleted。is_active() 与原 generating 语义一致。 - let was_gen = session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false); + // was_gen 读点(B-Phase2):改读无锁 conv_states.is_active()(Generating/Compressed), + // 用于判断「原是否在生成」以决定是否 emit AiCompleted。语义等价。 + let was_gen = state.conv_states.is_active(&target); // SW-260618-02:强制发送丢弃目标 conv 的旧审批,先终态化占位 tool_result,防占位随 messages // 残留下次发送喂给 LLM。仅清目标 conv 的 pending_approvals(retain 保留其他 conv)。 finalize_pending_placeholders(&mut *session, &target, "已取消"); @@ -1472,15 +1578,13 @@ pub async fn ai_chat_force_send( session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str())); { let conv = session.conv(&target); - // 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle 收敛)。 - // was_gen=false 时仍 Idle(幂等);非活跃态→Idle 非法仅 warn(不阻断强制发送语义)。 - match conv.conv_state.transition_to(ConvState::Idle) { - Ok(ns) => conv.conv_state = ns, - Err(e) => tracing::warn!( + // B-Phase3:conv_state 写切 ConvStateStore 单源。 + if let Err(e) = state.conv_states.transition(&target, ConvState::Idle) { + tracing::warn!( conv_id = %target, error = %e, - "[ai] 强制发送 ConvState→Idle 非法(不阻断强制发送)" - ), + "[ai] conv_states 强制发送→Idle 非法(不阻断强制发送)" + ); } conv.stop_flag.store(true, Ordering::SeqCst); } @@ -1512,6 +1616,10 @@ pub async fn ai_chat_force_send( // F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。 let user_msg_id = conv.messages.last_user_message_id(); // G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send) + let __hold1473 = __lock_t1473.elapsed(); + if __hold1473 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_chat_force_send:1473 持锁 {:?} (含 lock 等待)", __hold1473); + } (was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone()) }; @@ -1580,6 +1688,7 @@ pub async fn ai_chat_stop( // None/空 → fallback active(旧调用方兼容)。 conversation_id: Option, ) -> Result<(), String> { + let __lock_t1611 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; // 目标 conv:入参优先 → active。 let target = conversation_id.filter(|s| !s.is_empty()) @@ -1587,24 +1696,20 @@ pub async fn ai_chat_stop( .unwrap_or_default(); if target.is_empty() { // 无目标 conv 且无 active:无任何 conv 在跑(批4 顶层 generating 已退役),直接返回。 + let __hold1611 = __lock_t1611.elapsed(); + if __hold1611 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611); + } drop(session); return Ok(()); } - // 仅查目标 conv 的 generating(真并发下各 conv 独立)。 - // L2 读侧(双轨收口批1):统一读 conv_state.is_active()(Generating+Compressed),删除 - // CONV_STATE_ENABLED off 回退分支(enum 单路径)。用于判断停止按钮是否可点的预判断。 - let is_gen = session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false); + // B-Phase3:读侧切无锁 conv_states(单源收敛)。 + let is_gen = state.conv_states.is_active(&target); if !is_gen { return Ok(()); } // 目标 conv 是否有待审批(仅本 conv 的)。 - // path_auth 审批链阶段1:改调 session_state(target) 收敛状态机判定,替代手写两表合并。 - // 阶段3a 单真相源合并后:session_state 单表 any 判定(原双表 OR 合一)。 - // - // 兜底/快速回退(改一行即可): - // let has_pending = session.pending_approvals.values() - // .any(|a| a.conversation_id.as_deref() == Some(target.as_str())); - let has_pending = session.session_state(&target) == SessionState::AwaitingApproval; + let has_pending = session.session_state(&target, &state.conv_states) == SessionState::AwaitingApproval; if has_pending { // 审批等待态:loop 已退出,直接清理目标 conv 让其立即可用 // SW-260618-02:messages 不 clear,占位 tool_result 会残留,下次发送喂给 LLM。 @@ -1612,17 +1717,23 @@ pub async fn ai_chat_stop( finalize_pending_placeholders(&mut *session, &target, "会话已停止"); session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str())); let conv = session.conv(&target); - // 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle)。 - match conv.conv_state.transition_to(ConvState::Idle) { - Ok(ns) => conv.conv_state = ns, - Err(e) => tracing::warn!( - conv_id = %target, - error = %e, - "[ai] ai_chat_stop 审批分支 ConvState→Idle 非法(不阻断 emit AiCompleted)" - ), + // B-Phase3:conv_state 写切 ConvStateStore 单源(per_conv 字段已删)。 + { + let conv_states = app.state::().conv_states.clone(); + if let Err(e) = conv_states.transition(&target, ConvState::Idle) { + tracing::warn!( + conv_id = %target, + error = %e, + "[ai] conv_states ai_chat_stop 审批分支→Idle 非法(不阻断 emit AiCompleted)" + ); + } } conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启 let pinned_goals = conv.pinned_goals.clone(); + let __hold1611 = __lock_t1611.elapsed(); + if __hold1611 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611); + } drop(session); let ev = AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None, @@ -1645,6 +1756,10 @@ pub async fn ai_chat_stop( // Notify 仅承载「即时唤醒」,停止真值仍由 stop_flag 决定(stream_llm 唤醒后再判 flag)。 notify.notify_one(); let conv_id = Some(target); + let __hold1611 = __lock_t1611.elapsed(); + if __hold1611 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611); + } drop(session); // B-260615-13 兜底任务:loop 若 panic/异常退出漏发收尾,stop_flag 无人读, @@ -1655,28 +1770,32 @@ pub async fn ai_chat_stop( let app_handle = app.clone(); tauri::async_runtime::spawn(async move { tokio::time::sleep(std::time::Duration::from_secs(3)).await; + let __lock_t1686 = std::time::Instant::now(); let mut session = session_arc.lock().await; let target_owned = conv_id.clone().unwrap_or_default(); - // still_gen 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed), - // 3秒后重检是否仍在生成。is_active() 与原 generating 语义一致。 + // B-Phase3:读侧切无锁 conv_states。 let still_gen = if target_owned.is_empty() { false } else { - session.conv_read(&target_owned).map(|c| c.conv_state.is_active()).unwrap_or(false) + app.state::().conv_states.is_active(&target_owned) }; if still_gen { - // 3秒超时强制收尾:generating bool 已退役,改 ConvState 迁移(Generating→Idle)收敛生成态。 - // 正常路径由 guard.drop 复位,此处仅超时兜底(loop 未及时退出场景)。 - let conv = session.conv(&target_owned); - match conv.conv_state.transition_to(ConvState::Idle) { - Ok(ns) => conv.conv_state = ns, - Err(e) => tracing::warn!( - conv_id = %target_owned, - error = %e, - "[ai] ai_chat_stop 3秒超时 ConvState→Idle 非法(不阻断 emit AiCompleted)" - ), + // B-Phase3:conv_state 写切 ConvStateStore 单源。 + { + let conv_states = app.state::().conv_states.clone(); + if let Err(e) = conv_states.transition(&target_owned, ConvState::Idle) { + tracing::warn!( + conv_id = %target_owned, + error = %e, + "[ai] conv_states 3秒超时→Idle 非法(不阻断 emit AiCompleted)" + ); + } + } + let pinned_goals = session.conv(&target_owned).pinned_goals.clone(); + let __hold1686 = __lock_t1686.elapsed(); + if __hold1686 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_chat_stop:1686 持锁 {:?} (含 lock 等待)", __hold1686); } - let pinned_goals = conv.pinned_goals.clone(); drop(session); // 释放锁后再 emit,避免持锁调 runtime emit let ev = AiChatEvent::AiCompleted { total_tokens: 0, @@ -1689,6 +1808,11 @@ pub async fn ai_chat_stop( let _ = app_handle.emit("ai-chat-event", ev.clone()); // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。 let _ = app_handle.state::().ai_event_bus.publish_event(ev); + } else { + let __hold1686 = __lock_t1686.elapsed(); + if __hold1686 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_chat_stop:1686 持锁 {:?} (含 lock 等待)", __hold1686); + } } }); @@ -1713,13 +1837,16 @@ pub async fn ai_continue_loop( conversation_id: String, ) -> Result { { + let __lock_t1744 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,可对任意 conv 操作 // (含切走后的后台 conv),移除 active 一致性校验(真并发下后台 conv 也应可续跑)。 - // 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed)。达 max 时 - // generating=true(conv_state=Generating),is_active() 返回 true 符合预期。 - let is_gen = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false); - if !is_gen { + // B-Phase3:读侧切无锁 conv_states。 + if !state.conv_states.is_active(&conversation_id) { + let __hold1744 = __lock_t1744.elapsed(); + if __hold1744 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744); + } return Err("AI 未在暂停态,无需继续".to_string()); } let conv = session.conv(&conversation_id); @@ -1728,6 +1855,10 @@ pub async fn ai_continue_loop( // F-260616-11 决策 a: 达 max 续跑重计 iteration(F-260616-03 决策 a,用户点继续=授权重来)。 // 审批续跑(ai_approve)累计不重置见另路径;本路径 start_iteration 传 0,loop 从头计数。 conv.iteration_used = 0; + let __hold1744 = __lock_t1744.elapsed(); + if __hold1744 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744); + } } // 复用审批恢复续 loop 入口(不重写 loop),其内部 spawn run_agentic_loop // F-09 B 批2:传 conv_id(IPC 参数 conversation_id)。 @@ -1751,28 +1882,34 @@ pub async fn ai_stop_loop( ) -> Result { let pinned_goals: Vec; { + let __lock_t1782 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验 // (真并发下后台 conv 也应可停止)。 - // 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed)。停止按钮判断 - // 是否在生成中(与 ai_continue_loop 同场景)。is_active() 符合语义。 - let is_gen = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false); - if !is_gen { + // B-Phase3:读侧切无锁 conv_states。 + if !state.conv_states.is_active(&conversation_id) { + let __hold1782 = __lock_t1782.elapsed(); + if __hold1782 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782); + } return Err("AI 未在暂停态,无需停止".to_string()); } let conv = session.conv(&conversation_id); // 置 stop_flag 双保险:防 try_continue 误判重启(与 ai_chat_stop 审批分支一致) conv.stop_flag.store(true, Ordering::SeqCst); - // 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle)。 - match conv.conv_state.transition_to(ConvState::Idle) { - Ok(ns) => conv.conv_state = ns, - Err(e) => tracing::warn!( + // B-Phase3:conv_state 写切 ConvStateStore 单源。 + if let Err(e) = state.conv_states.transition(&conversation_id, ConvState::Idle) { + tracing::warn!( conv_id = %conversation_id, error = %e, - "[ai] ai_stop_loop ConvState→Idle 非法(不阻断 emit AiCompleted)" - ), + "[ai] conv_states ai_stop_loop→Idle 非法(不阻断 emit AiCompleted)" + ); } pinned_goals = conv.pinned_goals.clone(); + let __hold1782 = __lock_t1782.elapsed(); + if __hold1782 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782); + } } // 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致) let ev = AiChatEvent::AiCompleted { diff --git a/src-tauri/src/commands/ai/commands/config.rs b/src-tauri/src/commands/ai/commands/config.rs index b17e05b..a1c0520 100644 --- a/src-tauri/src/commands/ai/commands/config.rs +++ b/src-tauri/src/commands/ai/commands/config.rs @@ -30,7 +30,7 @@ use crate::state::AppState; #[tauri::command] pub async fn ai_list_skills() -> Result, String> { // 命中进程内缓存(skills_cached 已 owned Vec,直接返回) - Ok(skills_cached()) + Ok(skills_cached().await) } /// 热重载技能列表(核心设计6:进程内重载,不重启生效)。 @@ -40,7 +40,7 @@ pub async fn ai_list_skills() -> Result, String> { #[tauri::command] pub async fn ai_reload_skills() -> Result, String> { invalidate_skills(); - Ok(skills_cached()) + Ok(skills_cached().await) } /// 设置 LLM 调用并发上限(运行时调整,立即生效) diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index 70e03d1..67cd42f 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -322,10 +322,10 @@ pub async fn ai_conversation_switch( drop(session); state.clear_session_allowed_dirs().await; let mut session = state.ai_session.lock().await; - // 读点(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),判断目标会话是否 - // 已在生成中(是否需要从 DB reload)。is_active() 与原 generating 语义一致(压缩期间后台 loop - // 仍持有 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息,应判为已在生成而跳过 reload)。 - let already_live = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false); + // 读点(B-Phase2):改读无锁 conv_states.is_active()(Generating/Compressed),判断目标会话是否 + // 已在生成中(是否需要从 DB reload)。语义等价(同一 ConvState 值)——压缩期间后台 loop + // 仍持有 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息,应判为已在生成而跳过 reload。 + let already_live = state.conv_states.is_active(&conversation_id); if !already_live { // 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。 // 已在生成:保留其 per_conv 现状(后台 loop 持有),messages 由 loop 自行维护。 @@ -480,6 +480,9 @@ pub async fn ai_conversation_delete( // F-260616-09 B 批4:删除 conv 时移除其 per_conv 条目(设计 §4.1 conv 存在性判据依赖此, // 旧 loop 检测 conv 不存在即退出)。per_conv 唯一真相源,删顶层 messages.clear 双写。 session.per_conv.remove(&conversation_id); + // B-Phase4:conv 删除同步清理 ConvStateStore 条目,防已删 conv 残留 + // Generating 态致 id 复用(同一 conv_id 重新创建)脏状态。 + state.conv_states.remove(&conversation_id); let was_active = session.active_conversation_id.as_deref() == Some(&conversation_id); if was_active { session.active_conversation_id = None; diff --git a/src-tauri/src/commands/ai/commands/provider.rs b/src-tauri/src/commands/ai/commands/provider.rs index cbba198..2180cac 100644 --- a/src-tauri/src/commands/ai/commands/provider.rs +++ b/src-tauri/src/commands/ai/commands/provider.rs @@ -27,8 +27,10 @@ use crate::state::AppState; use crate::commands::{err_str, now_millis}; // provider.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。 +// 使用 async 版本(`*_async`):IPC 在 async 上下文,同步 keyring 调用会卡 tokio runtime +// (Tauri 单线程 runtime,Windows COM / Linux D-Bus 慢场景累加尾延迟)。 use super::super::secret::{ - get_provider_secret, set_provider_secret, delete_provider_secret, + get_provider_secret_async, set_provider_secret_async, delete_provider_secret_async, }; // ============================================================ @@ -50,13 +52,41 @@ fn mask_api_key(key: &str) -> String { #[tauri::command] pub async fn ai_list_providers(state: State<'_, AppState>) -> Result, String> { let mut providers = state.ai_providers.list_all().await.map_err(err_str)?; + // 收敛 is_default 唯一性自愈:历史脏数据(早期 ai_set_provider 未互斥/F-04 前 + // ai_save_provider 曾直接置默认)可能让多条 provider 同为 is_default=true, + // UI 会显示多个"默认"徽章且 get_active_provider 取首条不可预期。此处检测到 + // 多于 1 条时,保留 created_at 最新的一条(与 ai_set_provider 语义一致:后设优先), + // 其余清零并落库。Settings 每次打开必经此 IPC,作为单点收敛入口。 + let defaults: Vec<&AiProviderRecord> = providers.iter().filter(|p| p.is_default).collect(); + if defaults.len() > 1 { + // created_at 最大者保留为默认(list_all 已按 created_at DESC 排序,首条即最新) + let keep_id = defaults[0].id.clone(); + tracing::warn!( + "[is_default] 检测到 {} 条 provider 同为默认,收敛保留 {} 清零其余", + defaults.len(), keep_id + ); + for p in &providers { + if p.is_default && p.id != keep_id { + let mut updated = p.clone(); + updated.is_default = false; + updated.updated_at = now_millis(); + state.ai_providers.update_full(&updated).await.map_err(err_str)?; + } + } + // 同步内存副本,供下方 mask 循环使用 + for p in &mut providers { + if p.id != keep_id { + p.is_default = false; + } + } + } // IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文。 // 迁移后 DB api_key 空 → 从 keyring 取真实密钥再 mask(前端看到 mask 但不持有明文) for p in &mut providers { let real = if !p.api_key.is_empty() { p.api_key.clone() // 未迁移(老明文) } else { - get_provider_secret(&p.id).unwrap_or_default() // 迁移后从 keyring + get_provider_secret_async(p.id.clone()).await.unwrap_or_default() // 迁移后从 keyring }; p.api_key = if real.is_empty() { String::new() } else { mask_api_key(&real) }; } @@ -103,7 +133,7 @@ pub async fn ai_save_provider( let provider_id = id.clone().unwrap_or_else(new_id); if !api_key.is_empty() { // 显式改/填密钥 → 写 keyring(现状不变) - if let Err(e) = set_provider_secret(&provider_id, &api_key) { + if let Err(e) = set_provider_secret_async(provider_id.clone(), api_key.clone()).await { return Err(format!("密钥保存到系统钥匙串失败: {}", e)); } } else if let Some(pid) = &id { @@ -116,10 +146,10 @@ pub async fn ai_save_provider( .map_err(err_str)?; if let Some(old) = old { if !old.api_key.is_empty() - && get_provider_secret(pid).is_none() + && get_provider_secret_async(pid.clone()).await.is_none() { // DB 有明文 且 keyring 无 → 即时迁移补密钥 - if let Err(e) = set_provider_secret(pid, &old.api_key) { + if let Err(e) = set_provider_secret_async(pid.clone(), old.api_key.clone()).await { return Err(format!( "检测到该提供商密钥尚未迁移至系统钥匙串,本次保存尝试即时迁移失败({})。\ 已保留原密钥未改动——请检查系统钥匙串权限后再次保存。", @@ -246,7 +276,7 @@ pub async fn ai_delete_provider( state.ai_providers.delete(&provider_id).await.map_err(err_str)?; // CR-260615-01:DB 已删则清 keyring 残留密钥(失败仅 warn 不阻断——无 DB 消费方, // 残留 keyring 不可复活;同 id 复用也不会读到旧密钥,因 set 覆盖写) - if let Err(e) = delete_provider_secret(&provider_id) { + if let Err(e) = delete_provider_secret_async(provider_id.clone()).await { tracing::warn!("[FR-S1] keyring 清理失败 {} (残留但无消费方,不阻断删除): {}", provider_id, e); } // 删除的若是当前默认,清空 active 指向,避免悬空 diff --git a/src-tauri/src/commands/ai/compress.rs b/src-tauri/src/commands/ai/compress.rs index cbb1374..c97ef8a 100644 --- a/src-tauri/src/commands/ai/compress.rs +++ b/src-tauri/src/commands/ai/compress.rs @@ -153,6 +153,13 @@ fn compress_segment_has_unclosed_placeholder(msgs: &[ChatMessage]) -> bool { /// 清理 LLM 返回的摘要:去首尾空白 / 包裹性 markdown 代码围栏,空则原样返回 /// (调用方据空值报错)。保留内部 markdown 结构(## 标题 / 列表),摘要本身就是结构化文本。 +/// +/// **退化检测(BUG-2026-07-07/P0-1)**:实测 9357c27c 会话 seq0 system 消息内容为 +/// 同一短语重复 41 次的 1879 字符废文本(LLM 退化输出 / repetition degeneration)。 +/// 原因:某些情况下 LLM(compress 用的 Standard 智力模型)会陷入重复循环,产出形如 +/// "现在更新项目信息:...让我更新项目描述和技术栈。" × N 的退化输出,被当作合法摘要 +/// 塞进 active system 消息,污染每一轮 prompt。此处检测后返空,调用方据空值报错降级 +/// 走关键词摘要兜底(extract_keyword_summary),不让退化输出污染上下文。 fn clean_summary(raw: &str) -> String { let t = raw.trim(); // 去掉可能的整体代码围栏(LLM 偶尔把整段包成 ```markdown ... ```) @@ -162,9 +169,66 @@ fn clean_summary(raw: &str) -> String { .unwrap_or(t) .trim_start_matches('\n'); let t = t.strip_suffix("```").unwrap_or(t).trim(); + // 退化检测:若文本含同一片段重复 ≥5 次,判定为 LLM 退化输出,返空让调用方降级。 + // 阈值 5 容忍正常排比(如列表项),只拦死循环式拼接(实测 41 次远超阈值)。 + if is_degenerated_repetition(t) { + tracing::warn!( + len = t.len(), + "[ai] 压缩摘要疑似 LLM 退化重复输出,丢弃降级走关键词兜底" + ); + return String::new(); + } t.to_string() } +/// 检测 LLM 退化重复输出:文本中是否存在某一长片段(≥8 字符)重复 ≥5 次。 +/// +/// 实测样本:`"我已经了解了项目结构。" + "现在更新项目信息:...让我更新项目描述和技术栈。" × 41` +/// 即「开头有引导句 + 后接同一片段重复 N 次」形态。 +/// +/// 检测策略:退化输出的重复片段在文本中点附近必然是周期性的,故取**文本中部**一段作候选, +/// 检查它在全文出现次数。取中点而非前缀,是因为退化输出常带不同前缀引导句 +/// ("我已经了解了项目结构。"),前缀取样会漏检。 +/// +/// **UTF-8 安全**:中文字符占 3 字节,按字节切片会切断字符致 panic。改按 `char_indices` +/// 取字符边界,确保片段始终在完整字符边界上。 +fn is_degenerated_repetition(text: &str) -> bool { + if text.len() < 40 { + return false; // 太短不可能退化 + } + const MIN_FRAGMENT_CHARS: usize = 8; // 最少 8 个字符(非字节) + const REPEAT_THRESHOLD: usize = 5; + // 收集字符边界位置(每个字符起始字节偏移),用于安全切片 + let char_boundaries: Vec = std::iter::once(0) + .chain(text.char_indices().map(|(i, _)| i)) + .collect(); + let char_count = char_boundaries.len() - 1; + if char_count < MIN_FRAGMENT_CHARS * REPEAT_THRESHOLD { + return false; // 字符太少不可能重复 5 次 + } + // 从文本中点取样候选片段(退化输出中段必然是周期性重复) + let mid_char = char_count / 2; + // 候选片段长度(字符数):从 MIN_FRAGMENT_CHARS 到 40 逐步试 + let max_frag_chars = 40.min(char_count / REPEAT_THRESHOLD); + for frag_chars in (MIN_FRAGMENT_CHARS..=max_frag_chars).rev() { + // 取中点附近的 frag_chars 个字符作候选(按字符边界切片) + let start_char = mid_char.saturating_sub(frag_chars / 2); + let end_char = (start_char + frag_chars).min(char_count); + let start_byte = char_boundaries[start_char]; + let end_byte = char_boundaries[end_char]; + let fragment = &text[start_byte..end_byte]; + if fragment.chars().count() < MIN_FRAGMENT_CHARS { + continue; + } + // 用子串计数(不依赖正则,避免引入依赖) + let count = text.matches(fragment).count(); + if count >= REPEAT_THRESHOLD { + return true; + } + } + false +} + #[cfg(test)] mod tests { use super::*; @@ -184,6 +248,38 @@ mod tests { assert_eq!(clean_summary(""), ""); } + // ── BUG-2026-07-07/P0-1: LLM 退化重复输出检测 ── + #[test] + fn clean_summary_rejects_degenerated_repetition() { + // 实测样本(9357c27c seq0):同一短语重复 41 次 + let fragment = "现在更新项目信息:基于代码分析,这是一个大型综合游戏平台后端。让我更新项目描述和技术栈。"; + let degenerated = format!("我已经了解了项目结构。{}", fragment.repeat(41)); + // 退化输出应返空(调用方据空值报错降级走关键词兜底) + assert_eq!(clean_summary(°enerated), "", "重复拼接的退化输出应被丢弃"); + } + + #[test] + fn clean_summary_keeps_normal_summary_with_lists() { + // 正常摘要(含列表项重复词)不应被误判为退化 + let normal = "## 意图\n- 整理文档\n## 决策\n- 使用方案A\n- 使用方案A\n- 使用方案A\n## 约束\n不超 100 字"; + assert_eq!(clean_summary(normal), normal, "正常摘要不应被误判"); + } + + #[test] + fn is_degenerated_repetition_detects_5x_repeat() { + // 5 次重复阈值边界测试 + let fragment = "这是一个测试片段abcdef"; + assert!(is_degenerated_repetition(&fragment.repeat(5))); + assert!(is_degenerated_repetition(&fragment.repeat(41))); + } + + #[test] + fn is_degenerated_repetition_ignores_short_text() { + // 短文本不触发(即使重复) + assert!(!is_degenerated_repetition("短文本短文本短文本短文本")); + assert!(!is_degenerated_repetition("ab")); + } + #[test] fn compress_prompt_returns_template_per_lang() { // 中文模板(默认) diff --git a/src-tauri/src/commands/ai/conversation.rs b/src-tauri/src/commands/ai/conversation.rs index 4c5bbb1..eeb7f97 100644 --- a/src-tauri/src/commands/ai/conversation.rs +++ b/src-tauri/src/commands/ai/conversation.rs @@ -1,6 +1,7 @@ //! 对话持久化 + Token 累加器 use std::sync::Arc; +use std::time::Duration; use tokio::sync::Mutex; @@ -12,6 +13,14 @@ use crate::commands::now_millis; use super::commands::message_to_record; use super::AiSession; +/// save_conversation 的总超时上限(秒)。 +/// +/// 内部 DB IO 经 Repo 方法已各自 spawn_blocking(不会阻塞 tokio runtime), +/// 但仍可能因 SQLite Mutex 长等待 / fs 卡 / 大体量 records 序列化等卡住整个调用, +/// 致 agentic loop 或 IPC 永久挂起。外层 timeout 防御:超时只 warn 不阻断 +/// (save 本就是非关键路径,失败已 warn + 用户可见结果仍在内存真相源)。 +const SAVE_CONVERSATION_TIMEOUT_SECS: u64 = 5; + /// Token 用量累加器(agent loop 生命周期内各轮叠加) /// /// 纯结构 + 方法:抽自 run_agentic_loop 的 `total_prompt`/`total_completion` 双计数器, @@ -153,6 +162,33 @@ pub(crate) async fn save_conversation( usage: Option<&df_ai::provider::TokenUsage>, model: Option<&str>, touch_updated_at: bool, +) { + // 外层 timeout(SAVE_CONVERSATION_TIMEOUT_SECS):超时只 warn 不阻断。 + // save 是非关键路径(失败已 warn,内存真相源不变),防 DB/fs 卡死拖垮 agentic loop / IPC。 + // timeout 仅作用于 DB 写入段(此处包住整个 inner,因 session 锁/序列化也属 save 周期, + // 超时即放弃本次 save,下次 loop 轮次会重新落库,幂等覆盖无副作用)。 + if let Err(_elapsed) = tokio::time::timeout( + Duration::from_secs(SAVE_CONVERSATION_TIMEOUT_SECS), + save_conversation_inner(session_arc, db, conv_id, usage, model, touch_updated_at), + ) + .await + { + tracing::warn!( + "save_conversation 超时 ({}s) 放弃 — conv_id={} (DB/fs 可能卡住,内存真相源不变,下次 loop 重落库)", + SAVE_CONVERSATION_TIMEOUT_SECS, + conv_id + ); + } +} + +/// save_conversation 内部实现(无超时,由外层 save_conversation 包 timeout 调用)。 +async fn save_conversation_inner( + session_arc: &Arc>, + db: &Arc, + conv_id: &str, + usage: Option<&df_ai::provider::TokenUsage>, + model: Option<&str>, + touch_updated_at: bool, ) { // 取 messages + 懒创建首次落库所需的 provider_id/created_at // 工具结果(content)超 8KB(B-260619-02:50KB→8KB,治 GLM 1214)时截断头尾各 ~3KB + 中段 @@ -164,30 +200,42 @@ pub(crate) async fn save_conversation( // loop 内 save 由 run_agentic_loop 入参 conv_id 透传;IPC 路径(commands.rs)save 也传 conv_id。 // conv() 惰性建:save 路径 conv 必然已建(send/regenerate/edit/switch 均先 conv());若极端 // 未建(如启动恢复无 live conv),conv() 建空 PerConvState,save 空 messages(幂等不污染)。 - let (persist_msgs, provider_id, created_at, pinned_goals) = { + // BUG-2026-07-19: lock 段只 clone 必要读(messages + 元数据),truncate 移出 lock。 + // 原 clone+truncate 全在 lock 内,对话历史长(多轮+大工具结果)时 clone+truncate 持锁可达秒级, + // 致 guard.reset/process_tool_calls 等 session lock 竞争超时(aichat 卡死连环:工具卡片不呈现+ + // 回答完卡住+下条进队列)。truncate 在 clone 副本上操作,锁外执行不污染 session 真相源,行为等价。 + let __lock_start = std::time::Instant::now(); + let (mut msgs, provider_id, created_at, pinned_goals) = { let mut session = session_arc.lock().await; - let mut msgs = session.conv(conv_id).messages.all_messages_clone(); - for m in &mut msgs { - // P0-2:tool result 是结构化 JSON(provider 读工具返回原样), - // 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL - // (实测 tool result read 大文件后落库 Invalid control character @pos3072)。 - // tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。 - if !matches!(m.role, df_ai::provider::MessageRole::Tool) { - m.content = truncate_for_persist(&m.content); - } - // F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片), - // 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。 - if let Some(parts) = m.parts.as_ref() { - m.parts = truncate_parts_for_persist(parts); - } - } - ( - msgs, + let __wait = __lock_start.elapsed(); + let cloned = ( + session.conv(conv_id).messages.all_messages_clone(), session.active_provider_id.clone(), session.active_conv_created_at.clone(), session.conv(conv_id).pinned_goals.clone(), - ) + ); + let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长) + cloned }; + let __lock_total = __lock_start.elapsed(); + if __lock_total > std::time::Duration::from_millis(50) { + eprintln!("[LOCK-DIAG] save clone 段持锁 {:?} (含等待,>50ms 报告,定位 session lock 长持有者)", __lock_total); + } + // truncate 在锁外(clone 副本上操作,不影响 session 真相源) + for m in &mut msgs { + // P0-2:tool result 是结构化 JSON(provider 读工具返回原样), + // 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL。 + // tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。 + if !matches!(m.role, df_ai::provider::MessageRole::Tool) { + m.content = truncate_for_persist(&m.content); + } + // F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片), + // 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。 + if let Some(parts) = m.parts.as_ref() { + m.parts = truncate_parts_for_persist(parts); + } + } + let persist_msgs = msgs; // 序列化当前 conv 的挂起审批快照:从 session.pending_approvals 筛选本 conv 条目, diff --git a/src-tauri/src/commands/ai/knowledge_inject.rs b/src-tauri/src/commands/ai/knowledge_inject.rs index 7206a94..59544ae 100644 --- a/src-tauri/src/commands/ai/knowledge_inject.rs +++ b/src-tauri/src/commands/ai/knowledge_inject.rs @@ -380,6 +380,7 @@ pub(crate) async fn inject_knowledge_into_prompt( // 同一条消息取 text + id(②口径修复):单次反向扫描,避免 text 过滤 is_active 而 id 不过滤 // 导致两值取自不同消息。 let (last_user_text, user_message_id) = { + let __lock_t = std::time::Instant::now(); let session = state.ai_session.lock().await; let msgs = session .conv_read(conv_id) @@ -389,6 +390,10 @@ pub(crate) async fn inject_knowledge_into_prompt( .iter() .rev() .find(|m| matches!(m.role, MessageRole::User) && m.is_active()); + let __hold = __lock_t.elapsed(); + if __hold > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] build_system_prompt_with_knowledge:383 持锁 {:?} (含 lock 等待)", __hold); + } match found { Some(m) => (m.content.clone(), m.id.clone()), None => (String::new(), None), @@ -434,6 +439,7 @@ pub(crate) async fn maybe_spawn_extraction( // true 即跳过(等价「提炼中」哨兵);spawn 后按 inserted 结果修正(见下)。 // 清位:trigger_extraction_now(手动按钮)强制清位,允许用户手动重提炼。 { + let __lock_t = std::time::Instant::now(); let mut session = session_arc.lock().await; // 一次 conv_read 读两个字段(knowledge_extracted + messages.len()),map 后借用即结束, // 后续 session.conv()(&mut self)不再冲突。 @@ -443,6 +449,10 @@ pub(crate) async fn maybe_spawn_extraction( .unwrap_or((false, 0)); // 守卫 1:已提炼过(或提炼中) → 跳过 + warn。 if already { + let __hold = __lock_t.elapsed(); + if __hold > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] maybe_spawn_extraction:437 持锁 {:?} (含 lock 等待)", __hold); + } tracing::warn!( conv_id = %conv_id, "[knowledge] 跳过自动提炼:本会话已提炼过/提炼中(去重标志置位,防重复刷 candidate);如需重提炼用手动按钮" @@ -451,10 +461,18 @@ pub(crate) async fn maybe_spawn_extraction( } // 守卫 2:消息数。conv_read 未建返 0(< min_messages 自然跳过,语义=空对话不注入知识)。 if (count as u32) < config.min_messages { + let __hold = __lock_t.elapsed(); + if __hold > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] maybe_spawn_extraction:437 持锁 {:?} (含 lock 等待)", __hold); + } return Ok(()); } // 原子预置位(TOCTOU 核心):释放锁前先占提炼槽位,杜绝并发窗口内重复 spawn。 session.conv(conv_id).knowledge_extracted = true; + let __hold = __lock_t.elapsed(); + if __hold > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] maybe_spawn_extraction:437 持锁 {:?} (含 lock 等待)", __hold); + } } let session_arc = session_arc.clone(); diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index f6a946f..bf462c5 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -45,7 +45,7 @@ pub mod stream_recv; pub mod title; pub mod tool_registry; -use agentic::conv_state::ConvState; +use agentic::conv_state::{ConvState, ConvStateStore}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -558,18 +558,13 @@ impl AiSession { /// `ai_chat_stop`(commands/chat.rs) 改调本方法替代各自手写的 has_pending, /// 收敛两处状态机判定到单一入口。 /// - /// 兜底/回退:若本方法判定异常需快速回退手写 has_pending,调用方注释已保留原 - /// 三路组合(generating + path_auth + risk)字面量,改一行即可切回。 - pub fn session_state(&self, conv_id: &str) -> SessionState { - // 阶段3a 单真相源合并后:两类挂起(path_auth + risk)合一进 pending_approvals, - // kind 字段区分语义但状态机只看「有无挂起」,单表 any 一次即可(消除原双表 OR 合并判断)。 + /// B-Phase3:conv_state 读侧切 ConvStateStore(单源收敛)。 + pub fn session_state(&self, conv_id: &str, conv_states: &ConvStateStore) -> SessionState { let has_pending = self.pending_approvals.values() .any(|a| a.conversation_id.as_deref() == Some(conv_id)); if has_pending { SessionState::AwaitingApproval - } else if self.conv_read(conv_id).map(|c| c.conv_state.is_active()).unwrap_or(false) { - // 读侧(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),判断是否在 - // 活跃生成。is_active() 与原 generating 语义一致(压缩期间 loop 仍活跃,应判为 Streaming)。 + } else if conv_states.is_active(conv_id) { SessionState::Streaming } else { SessionState::Idle @@ -613,7 +608,8 @@ mod tests_f09_per_conv { fn test_per_conv_state_new() { let s = PerConvState::new(); // 批3 收口:generating bool 已退役,生成态初值校验改读 conv_state(应为 Idle)。 - assert_eq!(s.conv_state, ConvState::Idle, "conv_state 初值应为 Idle"); + // B-Phase3:conv_state 已迁 ConvStateStore,PerConvState 不再持有。 + // conv_state 由 guard.new(Idle→Generating)经 conv_states.transition 迁移。 assert_eq!(s.iteration_used, 0, "iteration_used 初值应为 0"); assert!(s.agent_language.is_none(), "agent_language 初值应为 None"); assert!(s.model_override.is_none(), "model_override 初值应为 None"); @@ -644,7 +640,8 @@ mod tests_f09_per_conv { let ptr_first = { let s = session.conv("conv-a"); // 批3 收口:generating bool 已退役,首建会话生成态应为 Idle。 - assert_eq!(s.conv_state, ConvState::Idle); + // B-Phase3:conv_state 已迁 ConvStateStore。 + // conv_state 初值由 guard/入口保证,PerConvState 不再持有。 assert_eq!(s.iteration_used, 0); s as *const PerConvState }; @@ -816,10 +813,7 @@ mod tests_f09_per_conv { pub struct PerConvState { /// 对话历史(ContextManager:会话级消息真相源,裁剪仅影响发送视图) pub messages: ContextManager, - /// L2 统一状态机:对话生命周期状态(单一真相源,批2 持久化供读侧/前端消费)。 - /// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。 - /// 批3 双轨收口:generating bool 已退役,此 enum 成为生成态唯一真相源。 - pub conv_state: ConvState, + // B-Phase3:conv_state 字段已迁到 ConvStateStore(无锁真相源),此字段已删。 /// G1 目标钉扎真相源:从 LLM 工具调用推理的目标列表(内容态字段,与生命周期态正交)。 /// /// 治 R1(目标消息被压缩/compressed 物理出局,sanitize step0 过滤 is_active 致目标丢失): @@ -907,7 +901,7 @@ impl PerConvState { pub fn new() -> Self { Self { messages: ContextManager::new(ContextConfig::default()), - conv_state: ConvState::Idle, + pinned_goals: Vec::new(), stop_flag: Arc::new(AtomicBool::new(false)), notify: Arc::new(tokio::sync::Notify::new()), diff --git a/src-tauri/src/commands/ai/prompt.rs b/src-tauri/src/commands/ai/prompt.rs index 40560fa..28a70ed 100644 --- a/src-tauri/src/commands/ai/prompt.rs +++ b/src-tauri/src/commands/ai/prompt.rs @@ -77,6 +77,8 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str) - Ask for clarification if the user's intent is unclear\n\ - Prefer using tools to complete actions rather than just describing steps\n\ - When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n\ + - **Self-check before claiming completion**: Before announcing a task done, verify the result matches the original intent (grep key references / read back field values / cross-check). Fix discrepancies before declaring success; never claim all done without verification (repeatedly observed: missed reference renames, fields silently overwritten by later ops, forcing the user to manually verify)\n\ + - **Duplicate call detection**: If a tool (e.g. read_file/search_files) has already been called with identical arguments and returned successfully, do not call it again — the result is already in the conversation history; review the context instead of re-executing\n\ ## Focus\n\ - Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\ - When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\ @@ -101,6 +103,8 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str) - 如果不确定用户意图,先提问\n\ - 优先使用工具完成操作,而不是只描述步骤\n\ - 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\ + - **宣称任务完成前必须自检**:用 grep/读取关键引用/核对字段值等方式验证结果与原意图一致,发现偏差先修正再宣布完成;严禁不验证就宣称「全部完成」(实测反复出现:漏改引用名、字段被后续操作覆盖未发现,被迫用户人工核对)\n\ + - **重复调用检测**:如果某个工具(如 read_file/search_files)已用相同参数调用过且返回成功,不要重复调用——会话历史里已有结果,回顾上下文而非重复执行\n\ ## 聚焦准则\n\ - 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\ - 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\ diff --git a/src-tauri/src/commands/ai/remote_bridge.rs b/src-tauri/src/commands/ai/remote_bridge.rs index da625d3..70e3f28 100644 --- a/src-tauri/src/commands/ai/remote_bridge.rs +++ b/src-tauri/src/commands/ai/remote_bridge.rs @@ -738,33 +738,30 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args: /// - 读 `conv_state.is_active()`(Generating / Compressed 派生态均视为活跃,压缩期间 loop 仍活跃; /// 批3 收口后单一 enum 路径,开关 + generating bool 已退役) /// -/// 目标 conv 解析(对齐 ai_is_generating:278-284): +/// 目标 conv 解析(对齐 ai_is_generating): /// - 入参 conversation_id 非空优先 /// - 否则 fallback `active_conversation_id` /// - 都无 → 返 None(放行,无 conv 在跑) /// -/// 读 conv 状态用 `conv_read`(只读不创建),对齐 ai_is_generating 实现。 +/// 读 ConvState 用无锁 `conv_states`(B-Phase2,零锁竞争)。 async fn check_generating_reject( state: &State<'_, AppState>, conversation_id: Option<&str>, ) -> Option { - let session = state.ai_session.lock().await; - // 目标 conv:入参优先 → active 兜底。 - let target = conversation_id - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .or_else(|| session.active_conversation_id.clone()); + // 目标 conv:入参优先 → active 兜底(轻量 lock 取 active 单值)。 + let target = { + let session = state.ai_session.lock().await; + conversation_id + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .or_else(|| session.active_conversation_id.clone()) + }; let target = match target { Some(id) => id, None => return None, // 无目标 conv 且无 active:无任何 conv 在跑,放行 }; - // 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除 - // CONV_STATE_ENABLED off 回退分支(enum 单路径)。对齐 ai_is_generating,无 conv_read 视为 false 放行。 - let is_gen = session - .conv_read(&target) - .map(|c| c.conv_state.is_active()) - .unwrap_or(false); - if is_gen { + // B-Phase2:读侧切无锁 conv_states.is_active()(Generating/Compressed),对齐 ai_is_generating。 + if state.conv_states.is_active(&target) { Some(target) } else { None @@ -833,7 +830,7 @@ fn args_get_mention_spans(args: &Value, key: &str) -> Option #[cfg(test)] mod tests { use super::*; - use crate::commands::ai::{AiSession, PerConvState}; + use crate::commands::ai::PerConvState; use df_ai::context::ContextConfig; // ── MiniCommand 反序列化测试 ── @@ -920,97 +917,69 @@ mod tests { assert_eq!(args_get_bool(&args, "missing"), None); } - // ── R1 check_generating_reject 路径测试(隔离 AiSession,绕开 Tauri State 依赖) ── + // ── R1 check_generating_reject 路径测试 ── // - // check_generating_reject 需 `State`,单元测试难构造。 - // 改测 R1 核心判定逻辑:用 AiSession + PerConvState 模拟生成态, - // 复核 conv_read + conv_state.is_active() 口径对齐 ai_is_generating。 - // (批3 收口后单一 enum 路径,开关 + generating bool 已退役。) + // check_generating_reject 生产实现经 State 读 conv_states(B-Phase2), + // 单元测试难构造 Tauri State。改测 R1 核心判定口径:用 ConvStateStore(无锁,独立可构造) + // 模拟生成态,复核 is_active() 拒绝/放行判定对齐 check_generating_reject / ai_is_generating。 /// R1 判定:无目标 conv 且无 active → 放行(返 None)。 #[test] fn test_r1_no_target_pass() { - let session = AiSession::new(); - // 无 active,无入参 conv_id → 无任何 conv 在跑 - assert!( - session.active_conversation_id.is_none(), - "新 session 无 active conv" - ); - assert!( - session.per_conv.is_empty(), - "新 session per_conv 为空" - ); + // 无入参 conv_id 且 active_conversation_id=None 在生产侧由 check_generating_reject 早 return。 + // 此处只验空 store 的默认语义:任何 conv_id 读返 Idle 不活跃。 + let store = crate::commands::ai::agentic::conv_state::ConvStateStore::new(); + assert!(!store.is_active("any"), "空 store 读任意 conv_id 不活跃"); } - /// R1 判定:目标 conv 未在跑 → 放行(conv_state=Idle,is_active()=false)。 + /// R1 判定:目标 conv 未在跑 → 放行(Idle,is_active()=false)。 #[test] fn test_r1_target_idle_pass() { - use crate::commands::ai::agentic::conv_state::ConvState; - let mut session = AiSession::new(); - let conv = session.conv("conv-idle"); - conv.conv_state = ConvState::Idle; - // 批3 收口:读 conv_state.is_active() 口径(对齐 check_generating_reject / ai_is_generating)。 - // 开关已退役,直接读 enum 单一路径。 - let is_gen = session - .conv_read("conv-idle") - .map(|c| c.conv_state.is_active()) - .unwrap_or(false); + use crate::commands::ai::agentic::conv_state::{ConvState, ConvStateStore}; + let store = ConvStateStore::new(); + store.transition("conv-idle", ConvState::Idle).unwrap(); + let is_gen = store.is_active("conv-idle"); assert!(!is_gen, "conv-idle Idle 未在跑,is_gen 应为 false(放行)"); } - /// R1 判定:目标 conv 在跑 → 拒绝(conv_state=Generating,is_active()=true)。 + /// R1 判定:目标 conv 在跑 → 拒绝(Generating,is_active()=true)。 #[test] fn test_r1_target_generating_reject() { - use crate::commands::ai::agentic::conv_state::ConvState; - let mut session = AiSession::new(); - let conv = session.conv("conv-busy"); - // 批3 收口:generating bool 已退役,生成态经 conv_state=Generating 表达(单一真相源)。 - conv.conv_state = ConvState::Generating; - let is_gen = session - .conv_read("conv-busy") - .map(|c| c.conv_state.is_active()) - .unwrap_or(false); + use crate::commands::ai::agentic::conv_state::{ConvState, ConvStateStore}; + let store = ConvStateStore::new(); + // 生成态经 transition(Generating) 写入(单一真相源,无 generating bool 双轨)。 + store.transition("conv-busy", ConvState::Generating).unwrap(); + let is_gen = store.is_active("conv-busy"); assert!(is_gen, "conv-busy Generating 应拒绝"); } - /// R1 判定:conv_state 派生态(Generating)is_active()=true(单一 enum 路径)。 + /// R1 判定:conv_state 派生态(Compressed)is_active()=true(单一 enum 路径)。 /// - /// 验证批3 收口后读 conv_state.is_active() 判定活跃拒绝(enum 唯一真相源)。 + /// 验证读 is_active() 判定活跃拒绝(enum 唯一真相源),含 Compressed 派生。 #[test] fn test_r1_conv_state_active_reject() { - use crate::commands::ai::agentic::conv_state::ConvState; - // 批3 收口:开关已退役,enum 单一真相源,无条件 on 路径。 - let mut session = AiSession::new(); - let conv = session.conv("conv-state-busy"); - // 模拟状态机写收敛路径:conv_state=Generating(单一真相源,无 generating bool 双轨)。 - conv.conv_state = ConvState::Generating; - let is_gen = session - .conv_read("conv-state-busy") - .map(|c| c.conv_state.is_active()) - .unwrap_or(false); + use crate::commands::ai::agentic::conv_state::{ConvState, ConvStateStore}; + let store = ConvStateStore::new(); + // Compressed 派生态:loop 仍活跃,is_active()=true。 + store.transition("conv-state-busy", ConvState::Generating).unwrap(); + store.transition("conv-state-busy", ConvState::Compressed).unwrap(); + let is_gen = store.is_active("conv-state-busy"); assert!( is_gen, - "conv_state=Generating is_active() 应为 true(单一 enum 路径拒绝)" + "conv_state=Compressed is_active() 应为 true(单一 enum 路径拒绝)" ); } /// R1 目标 conv 解析:入参 conversation_id 优先于 active_conversation_id。 #[test] fn test_r1_target_input_priority() { - use crate::commands::ai::agentic::conv_state::ConvState; - let mut session = AiSession::new(); - session.active_conversation_id = Some("conv-active".to_string()); + use crate::commands::ai::agentic::conv_state::{ConvState, ConvStateStore}; + let store = ConvStateStore::new(); // active 在跑(Generating),但入参 conv-other 未跑(Idle) → 入参优先,应放行 - session.conv("conv-active").conv_state = ConvState::Generating; - let _ = session.conv("conv-other"); // 创建但 Idle(默认) - let active_gen = session - .conv_read("conv-active") - .map(|c| c.conv_state.is_active()) - .unwrap_or(false); - let other_gen = session - .conv_read("conv-other") - .map(|c| c.conv_state.is_active()) - .unwrap_or(false); + store.transition("conv-active", ConvState::Generating).unwrap(); + // conv-other 不写入(默认 Idle) + let active_gen = store.is_active("conv-active"); + let other_gen = store.is_active("conv-other"); assert!(active_gen, "conv-active 在跑"); assert!(!other_gen, "conv-other 未跑"); // 入参 conv-other 优先 → 判定 conv-other Idle → 放行 @@ -1019,12 +988,12 @@ mod tests { // ── PerConvState 初值对齐(确保 R1 测试基线 conv_state=Idle) ── - /// PerConvState::new() conv_state 初值 Idle(R1 放行基线)。 + /// B-Phase3:PerConvState.conv_state 字段已迁 ConvStateStore。 #[test] - fn test_per_conv_state_conv_state_default_idle() { - use crate::commands::ai::agentic::conv_state::ConvState; - let _ = ContextConfig::default(); // 触发 ContextConfig default 可用(避免 unused) - let s = PerConvState::new(); - assert_eq!(s.conv_state, ConvState::Idle, "PerConvState::new conv_state 初值应为 Idle"); + fn test_conv_state_store_default_idle() { + use crate::commands::ai::agentic::conv_state::ConvStateStore; + let store = ConvStateStore::new(); + assert_eq!(store.get("nonexistent"), crate::commands::ai::agentic::conv_state::ConvState::Idle, + "ConvStateStore 不存在的 conv_id 返 Idle"); } } diff --git a/src-tauri/src/commands/ai/skills.rs b/src-tauri/src/commands/ai/skills.rs index 845844d..dbc17ec 100644 --- a/src-tauri/src/commands/ai/skills.rs +++ b/src-tauri/src/commands/ai/skills.rs @@ -287,16 +287,23 @@ type SkillsGuard = RwLockReadGuard<'static, Option>>; /// /// 返 `RwLockReadGuard>>`,调用方解 `*guard` 得 `&Vec`。 /// 懒初始化走双检锁:先读锁查 Some(快),None 时释放 → 扫盘 → 写锁填回 → 读锁重取。 -fn skills_lock() -> SkillsGuard { - // 快路径:读锁命中 +/// +/// P1-260617-3:`scan_skills` 同步递归 `fs::read_dir` + `read_to_string`(plugins/marketplaces +/// 多层嵌套,Windows 文件多时同步阻塞 tokio runtime)。本函数改 async,慢路径扫盘包 +/// `spawn_blocking` 隔离(对齐 commands/project.rs detect_stack 模式)。快路径(读锁命中)仍同步无 fs。 +async fn skills_lock_async() -> SkillsGuard { + // 快路径:读锁命中(无 fs,纯内存) { let g = RwLock::read(&SKILLS).expect("SKILLS poisoned"); if g.is_some() { return g; } } - // 慢路径:扫盘 + 写锁填回 - let scanned = scan_skills().skills; + // 慢路径:扫盘(spawn_blocking 隔离同步 fs 递归,防阻塞 tokio runtime) + let scanned = tokio::task::spawn_blocking(scan_skills) + .await + .map(|res| res.skills) + .unwrap_or_default(); { let mut g = RwLock::write(&SKILLS).expect("SKILLS poisoned"); // 另一线程可能已填,二次检查(双检锁) @@ -314,8 +321,11 @@ fn skills_lock() -> SkillsGuard { /// /// 替代原 `OnceLock::get_or_init` 路径:返 owned `Vec`(clone), /// 因 RwLock 不能返 `&'static`。调用方(config.rs:30 / read_skill_content_stripped)已同步适配。 -pub(crate) fn skills_cached() -> Vec { - let g = skills_lock(); +/// +/// P1-260617-3:改 async,慢路径(首次/重扫)走 `skills_lock_async` → spawn_blocking +/// 隔离同步 fs 防阻塞 tokio runtime(Tauri 单线程 runtime)。快路径(读锁命中)无 fs。 +pub(crate) async fn skills_cached() -> Vec { + let g = skills_lock_async().await; g.clone().unwrap_or_default() } @@ -332,11 +342,25 @@ pub(crate) fn invalidate_skills() { /// /// 核心设计6:注入用正文,避免 YAML 头噪声污染 system prompt。 /// 缓存未命中返 None;文件读失败返 None。 -pub(crate) fn read_skill_content_stripped(name: &str) -> Option { - let g = skills_lock(); - let skills = g.as_ref()?; - let info = skills.iter().find(|s| s.name == name)?; - let md = fs::read_to_string(&info.path).ok()?; +/// +/// P1-260617-3:改 async,缓存懒初始化(可能触发扫盘)走 spawn_blocking 防阻塞 runtime。 +/// +/// 注:guard(std::sync::RwLockReadGuard 非 Send)必须在 spawn_blocking 的 .await 前 drop, +/// 否则非 Send 跨 await 点致 future 不 Send(MentionResolver 要求 Send)。guard 用 { } 限作用域。 +pub(crate) async fn read_skill_content_stripped(name: String) -> Option { + // 在作用域内取 path 后立即 drop guard,避免非 Send guard 跨 spawn_blocking await 点 + let path: String = { + let g = skills_lock_async().await; + let skills = g.as_ref()?; + let info = skills.iter().find(|s| s.name == name)?; + info.path.clone() + }; + // 单文件读取仍是同步 fs,但只一次小读;持续阻塞风险远低于扫盘递归。 + // 仍包 spawn_blocking 与慢路径一致(对齐 detect_stack:防 Windows fs 调度慢)。 + let md = tokio::task::spawn_blocking(move || fs::read_to_string(&path)) + .await + .ok()? + .ok()?; Some(strip_frontmatter(&md)) } diff --git a/src-tauri/src/commands/ai/stream_recv.rs b/src-tauri/src/commands/ai/stream_recv.rs index 58b7ed8..bb89804 100644 --- a/src-tauri/src/commands/ai/stream_recv.rs +++ b/src-tauri/src/commands/ai/stream_recv.rs @@ -1,11 +1,11 @@ //! 流式接收 LLM 响应 use std::collections::HashMap; +use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::time::Duration; use tauri::{AppHandle, Emitter, Manager}; -use futures::StreamExt; use tracing::warn; use df_ai::provider::{CompletionRequest, LlmProvider}; @@ -21,39 +21,30 @@ use super::{AiChatEvent, ToolCallDraft}; /// timeout/connect 关键词分类。 /// /// 返回 `(status_or_class, raw)`:status 取文本中首个三位数;否则按关键词给 timeout/connect/unknown。 -fn extract_error_diag(e: &anyhow::Error) -> (String, String) { - let raw = e.to_string(); - - // 1) 抠 HTTP 状态码:匹配 provider bail 串里的 "错误 4xx/5xx" 或 reqwest 的 "HTTP status"。 - // R-P2-6:原实现按字节窗口 `&bytes[i..i+3]` 切片——若窗口恰好切在多字节 UTF-8 字符中间会 panic - // (依赖中文恰好 3 字节、状态码恰为 ascii 的巧合)。改为按 char 迭代 + 前后非数字边界判断, - // 既 UTF-8 安全又顺便修复"长数字串(如端口号 14012)内嵌 401 误命中"的潜在问题。 - // 白名单码(首位 4/5,故只查 4xx/5xx 区段,减少无效匹配): +/// 从字符串(非 anyhow)提取诊断信息,用于 mpsc channel 中来自专用线程的错误字符串。 +fn extract_error_diag_from_str(raw: &str) -> (String, String) { + // 1) 抠 HTTP 状态码 const HTTP_CODES: &[&str] = &[ "400", "401", "403", "404", "408", "409", "413", "422", "429", "500", "502", "503", "504", ]; - // 把 raw 按 char 收集,索引即 char 下标(非字节),边界判断用 char 安全 let chars: Vec = raw.chars().collect(); let n = chars.len(); let mut i = 0; while i + 3 <= n { - // 窗口必须是三个 ascii 数字 if chars[i].is_ascii_digit() && chars[i + 1].is_ascii_digit() && chars[i + 2].is_ascii_digit() { let code: String = chars[i..i + 3].iter().collect(); - // 前后边界必须非数字(否则会从端口号 14012 里抠出 401) let prev_ok = i == 0 || !chars[i - 1].is_ascii_digit(); let next_ok = i + 3 == n || !chars[i + 3].is_ascii_digit(); if prev_ok && next_ok && HTTP_CODES.contains(&code.as_str()) { - return (format!("HTTP {}", code), raw); + return (format!("HTTP {}", code), raw.to_string()); } - i += 3; // 已确认是三连数字,跳过避免窗口重叠重复扫 + i += 3; continue; } i += 1; } - // 2) 传输层分类(reqwest Display 文本特征),不命名 reqwest 类型 let lower = raw.to_lowercase(); let class = if lower.contains("timeout") || lower.contains("超时") { "timeout" @@ -64,13 +55,20 @@ fn extract_error_diag(e: &anyhow::Error) -> (String, String) { } else { "unknown" }; - (class.to_string(), raw) + (class.to_string(), raw.to_string()) +} + +/// 从 anyhow::Error 提取诊断信息 +#[allow(dead_code)] +fn extract_error_diag(e: &anyhow::Error) -> (String, String) { + extract_error_diag_from_str(&e.to_string()) } /// AiError 诊断消息的上下文,区分「建连/首字节失败」与「流中途断」两类。 #[derive(Copy, Clone, Eq, PartialEq, Debug)] pub(crate) enum DiagKind { /// `provider.stream()` 直接返回 Err:连接/鉴权/HTTP non-2xx 等 + #[allow(dead_code)] Init, /// 流已建立,next() 返回 Err:SSE 传输断/解析错等 MidStream, @@ -144,282 +142,335 @@ pub(crate) enum StreamResult { /// 前端 watchdog 据此 reset,区分「LLM 在跑」与「真断」(避免空气泡误报中断) /// - `stop_notify.notified()`:用户点停止即时打断,不再等 chunk 到或 120s idle timeout pub(crate) async fn stream_llm( - provider: &dyn LlmProvider, + provider: Arc, request: CompletionRequest, app_handle: &AppHandle, stop_flag: &AtomicBool, notify: &tokio::sync::Notify, conv_id: &str, ) -> StreamResult { - /// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断 - const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(120); + /// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断 + /// BUG-2026-07-17:原 120s 太长,后端挂死时用户等太久。改为 45s,正常流式每 chunk 间隔 + /// 远不到 45s(即使模型思考间隙),超时即判为连接异常走保文/报错路径 + const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(45); + /// BUG-2026-07-08: 首 chunk 超时(等首字节)。 + /// 正常 provider 首 token <3s(即使 48 工具定义 + 长上下文),10s 足够宽容; + /// 已有数据后中途静默仍用 STREAM_IDLE_TIMEOUT(120s,允许模型思考间隙)。 + const FIRST_CHUNK_TIMEOUT: Duration = Duration::from_secs(10); /// 心跳间隔:静默期向前端报「LLM 仍在跑」,reset watchdog const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); - match provider.stream(request).await { - Ok(mut stream) => { - let mut full_text = String::new(); - let mut tool_calls_acc: HashMap = HashMap::new(); - let mut finished_received = false; - let mut stopped = false; - let mut final_usage: Option = None; - // BUG-260617-12: DeepSeek thinking 模式推理内容累积(多轮需回传) - let mut reasoning_content_acc: Option = None; + // ================================================================ + // BUG-2026-07-17 根治: 整个 LLM 流式请求运行在独立 OS 线程的 + // 专用 tokio runtime 上,通过 mpsc channel 将 chunk 流式送回主 runtime。 + // ================================================================ + // + // 根因: Tauri 在 Windows 上使用单线程 tokio runtime。provider.stream() + // 内部(eventsource-stream/reqwest TLS/网络建连)存在同步阻塞操作, + // 阻塞唯一工作线程导致 tokio 计时器系统无法推进,timeout 不触发。 + // + // 方案: + // 1. 独立 OS 线程 + 多线程 tokio runtime 执行完整 LLM 请求 + // (provider.stream + stream.next 循环),隔离同步阻塞。 + // 2. 通过 mpsc channel 将 StreamChunk 逐块传回主 runtime。 + // 3. reqwest Response/Stream 的生命周期不跨 runtime,避免 + // "error decoding response body"(runtime 状态不兼容)。 + // + // 资源: 专用线程运行至 LLM 流结束或 mpsc receiver 被 drop(超时/停止)。 + let (chunk_tx, mut chunk_rx) = tokio::sync::mpsc::channel::< + std::result::Result + >(64); + let provider_for_thread = provider.clone(); + let conv_id_owned = conv_id.to_string(); - // B-260615-15:heartbeat interval 提至 loop 外复用,避免每轮重建计时器 - // (每轮重建会丢已积累的节拍,且 interval 首次 tick 立即返回的特性会被误用)。 - // tokio interval 首 tick 立即返回——此处先丢弃首 tick,让心跳等满首个 30s 静默期才发 - // (心跳语义是"静默期仍在跑",循环入口立即报无意义且会与 stream.next() 抢分支错过首 chunk)。 - let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL); - heartbeat.tick().await; - - loop { - // B-260615-04:stop 即时打断。stream.next() 阻塞等 chunk 时, - // 用户点停止需等 chunk 到或 120s idle timeout 才轮到此处检查—— - // 合并到下方 select! 的 stop_notify 分支后此处为快路径(非阻塞首检)。 - // stop_flag 可能被 stopChat() 在 select! 阻塞期间置位, - // select! 的 stop_notify 分支会唤醒;此处保留作冗余快检(非阻塞)。 - if stop_flag.load(std::sync::atomic::Ordering::SeqCst) { - stopped = true; - break; - } - - // 三分支 select!(B-260615-02 + B-260615-04 合并): - // 1) stream.next():正常 chunk(idle timeout 120s 包裹,真断返回 InitFailed 交调用方处理) - // 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog - // 3) stop_notify.notified():用户停止即时打断 - // - // 注意:stop_flag 是 AtomicBool 无 async 通知能力—— - // 此处用「timeout 包 stream.next() + 进入循环前/后查 stop_flag + 循环内 30s 心跳 tick」 - // 间接实现「≤30s 感知 stop」(每轮 select! 至多 120s,但心跳 tick 30s 一次会 - // 触发 select! 返回 → 循环回顶部 stop_flag 快检)。无 Notify 依赖,改动最小。 - tokio::select! { - // 1) 正常 chunk(idle timeout 包裹) - chunk_result = tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()) => { - match chunk_result { - Err(_elapsed) => { - // UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: - // idle timeout 属 MidStream 类失败——已有文本则保文(Partial),不重试。 - // 空文本无文可保,UX-260618-15: 不再 emit AiError(retryable=true 重试路径, - // emit 权交 agentic 重试耗尽时统一发,避免 N 次重试 N+1 气泡)。 - if full_text.is_empty() && tool_calls_acc.is_empty() { - return StreamResult::InitFailed { - retryable: true, - error: "流式响应超时(120 秒无数据,连接可能已断开)".to_string(), - }; - } - warn!( - provider = %provider.name(), - conv_id = %conv_id, - text_len = full_text.len(), - "[ai] 流中途 idle timeout,保文不重试(incomplete)", - ); - return StreamResult::Partial { - text: full_text, - tool_calls: tool_calls_acc, - usage: final_usage.unwrap_or_default(), - reasoning_content: reasoning_content_acc, - }; - } - Ok(None) => break, // 流正常结束 - Ok(Some(chunk_result)) => match chunk_result { - Ok(chunk) => { - if !chunk.delta.is_empty() { - full_text.push_str(&chunk.delta); - // L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 - let ev = AiChatEvent::AiTextDelta { - delta: chunk.delta, - conversation_id: Some(conv_id.to_string()), - }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); - } - if let Some(tc_deltas) = &chunk.tool_calls { - for tc_delta in tc_deltas { - let draft = tool_calls_acc.entry(tc_delta.index).or_default(); - if let Some(id) = &tc_delta.id { draft.id = id.clone(); } - if let Some(name) = &tc_delta.function_name { draft.name.push_str(name); } - if let Some(args) = &tc_delta.function_arguments { draft.args.push_str(args); } - } - } - if let Some(u) = &chunk.usage { - final_usage = Some(u.clone()); - } - // BUG-260617-12: DeepSeek thinking 模式推理内容累积 - if let Some(ref rc) = chunk.reasoning_content { - reasoning_content_acc.get_or_insert_with(String::new).push_str(rc); - } - // provider 流式错误事件(Anthropic SSE `type=="error"` 等): - // UX-2025-04 / CR-30-2 / 决策 a1: MidStream 类失败——已有文本则保文(Partial), - // 不重试。空文本无文可保,返回 InitFailed(保守 retryable=true, - // err_msg 不可靠解析状态码,默认按可重试交调用方决定)。 - if let Some(err_msg) = &chunk.error { - warn!( - provider = %provider.name(), - conv_id = %conv_id, - error = %err_msg, - text_len = full_text.len(), - "[ai] provider 流式错误事件", - ); - // UX-2025-04 / CR-30-2 / 决策 a1: MidStream 类失败——已有文本则保文(Partial), - // 不重试。空文本无文可保,UX-260618-15: 不再 emit AiError(保守 retryable=true, - // emit 权交 agentic 重试耗尽时统一发,避免 N 次重试 N+1 气泡)。 - if full_text.is_empty() && tool_calls_acc.is_empty() { - return StreamResult::InitFailed { - retryable: true, - error: fmt_diag( - provider.name(), - DiagKind::MidStream, - "stream-error", - err_msg, - ), - }; - } - return StreamResult::Partial { - text: full_text, - tool_calls: tool_calls_acc, - usage: final_usage.unwrap_or_default(), - reasoning_content: reasoning_content_acc, - }; - } - if chunk.finished { - finished_received = true; - break; - } - } - Err(e) => { - // 诊断:流中途错误(多为 SSE 传输断),补 provider 标识 + HTTP 状态/分类 + 原始文本 - let (status_or_class, raw) = extract_error_diag(&e); - warn!( - provider = %provider.name(), - status = %status_or_class, - conv_id = %conv_id, - error = %raw, - text_len = full_text.len(), - "[ai] 流式接收中途错误", - ); - // UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: MidStream chunk Err—— - // 已有文本则保文(Partial),不重试。空文本无文可保,返回 - // InitFailed{retryable=classify_status_or_class(status_or_class)} - // (4xx Fatal 立即放弃,5xx/429/timeout/connect 可重试)。 - if full_text.is_empty() && tool_calls_acc.is_empty() { - // UX-260618-15: 不再 emit AiError。retryable=true 走重试路径, - // retryable=false(4xx Fatal)也交 agentic Fatal 分支统一 emit - // (InitFailed 携带 retryable + error,agentic 据此决定重试或 Fatal emit)。 - return StreamResult::InitFailed { - retryable: classify_status_or_class(&status_or_class), - error: fmt_diag( - provider.name(), - DiagKind::MidStream, - &status_or_class, - &raw, - ), - }; - } - return StreamResult::Partial { - text: full_text, - tool_calls: tool_calls_acc, - usage: final_usage.unwrap_or_default(), - reasoning_content: reasoning_content_acc, - }; - } - } - } - } - // 2) 心跳:静默期 30s 发 AiHeartbeat,前端 watchdog reset(B-260615-02) - _ = heartbeat.tick() => { - // 心跳只在「仍在等下一 chunk」时有意义——若 stop_flag 已置,顶部快检会 break,无需发心跳 - // L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 - let ev = AiChatEvent::AiHeartbeat { - conversation_id: Some(conv_id.to_string()), + std::thread::spawn(move || { + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + tracing::error!( + conv_id = %conv_id_owned, + "[ai] 创建专用 tokio runtime 失败: {}", + e, + ); + return; + } + }; + // BUG-2026-07-17 双重保险:在专用 runtime 内部也加 120s 超时。 + // 即使 provider.stream() 在专用线程上意外阻塞(理论不应发生,因专用 runtime + // 多线程可推进独立 timer wheel),此 timeout 保证线程最终退出不永久挂起。 + rt.block_on(async move { + use futures::StreamExt; + let stream_result = tokio::time::timeout( + std::time::Duration::from_secs(120), + provider_for_thread.stream(request), + ).await; + match stream_result { + Ok(Ok(mut stream)) => { + while let Some(chunk_result) = stream.next().await { + let sent = match chunk_result { + Ok(chunk) => chunk_tx.send(Ok(chunk)).await, + Err(e) => chunk_tx.send(Err(e.to_string())).await, }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - let _ = app_handle.state::().ai_event_bus.publish_event(ev); - } - // 3) 即时停止唤醒(B-260615-14):用户点 stop → ai_chat_stop 置 stop_flag 后 notify_one()。 - // stream.next() 正阻塞等 chunk 时立即被唤醒,不再等 30s 心跳 tick 或 120s idle timeout。 - // 唤醒后判 stop_flag:真值仍由 AtomicBool 决定,Notify 仅承载「即时唤醒」职责 - // (防误唤醒继续跑——若非 stop 触发的 notify,flag 仍 false 则照常进下一轮循环)。 - _ = notify.notified() => { - if stop_flag.load(std::sync::atomic::Ordering::SeqCst) { - stopped = true; + if sent.is_err() { + // receiver dropped (timeout/stop/error) — 停止发送 break; } } } - } - - // 用户停止:已生成文本(可能残缺)交调用方入库展示。Complete(incomplete=false)—— - // 用户主动停止语义非异常中断,正常入库 + AiCompleted(决策 a1: 不重试路径)。 - if stopped { - return StreamResult::Complete { - text: full_text, - tool_calls: tool_calls_acc, - usage: final_usage.unwrap_or_default(), - reasoning_content: reasoning_content_acc, - }; - } - - // 断连检测:流尽但从未收到 finished 信号 = 异常中断。 - // B-260615-05:空内容无 finished emit AiError + InitFailed{retryable=true}(无文可保,交重试)。 - // UX-2025-04:有 partial_text 则保文(Partial),不 emit AiError,不重试。 - if !finished_received { - if full_text.is_empty() && tool_calls_acc.is_empty() { - // B-260615-05:空内容无 finished。UX-260618-15: 不再 emit AiError(retryable=true - // 重试路径,emit 权交 agentic 重试耗尽时统一发)。 - return StreamResult::InitFailed { - retryable: true, - error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(), - }; + Ok(Err(e)) => { + let _ = chunk_tx.send(Err(e.to_string())).await; } - warn!( - provider = %provider.name(), - conv_id = %conv_id, - text_len = full_text.len(), - "[ai] 流尽未收到 finished 但有 partial_text,保文不重试(incomplete)", - ); - return StreamResult::Partial { - text: full_text, - tool_calls: tool_calls_acc, - usage: final_usage.unwrap_or_default(), - reasoning_content: reasoning_content_acc, + Err(_elapsed) => { + tracing::error!( + conv_id = %conv_id_owned, + "[ai] 专用 runtime 内部超时(120s),provider.stream 未返回" + ); + let _ = chunk_tx.send(Err("LLM 调用超时(120s)".to_string())).await; + } + } + }); + }); + + // 在主 runtime 上通过 mpsc 接收 chunk,应用 timeout/heartbeat/stop 逻辑 + let mut full_text = String::new(); + let mut tool_calls_acc: HashMap = HashMap::new(); + let mut finished_received = false; + let mut stopped = false; + let mut final_usage: Option = None; + // BUG-260617-12: DeepSeek thinking 模式推理内容累积(多轮需回传) + let mut reasoning_content_acc: Option = None; + + // B-260615-15:heartbeat interval 提至 loop 外复用,避免每轮重建计时器 + // (每轮重建会丢已积累的节拍,且 interval 首次 tick 立即返回的特性会被误用)。 + // tokio interval 首 tick 立即返回——此处先丢弃首 tick,让心跳等满首个 30s 静默期才发 + // (心跳语义是"静默期仍在跑",循环入口立即报无意义且会与 rx.recv() 抢分支错过首 chunk)。 + let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL); + heartbeat.tick().await; // 丢弃首 tick + + // BUG-2026-07-14 根治: wall-clock deadline 替代 select! 内 timeout 包裹。 + // idle_deadline 是绝对时刻:"下一次 chunk 必须在此前到达,否则判定超时"。 + // - 首 chunk 阶段: idle_deadline = now + 10s (FIRST_CHUNK_TIMEOUT) + // - 收到首 chunk 后: idle_deadline = now + 120s (STREAM_IDLE_TIMEOUT),每次收到 chunk 更新 + // 心跳打断 select! 后回到循环顶部,deadline 检查在 select! 之前(不受竞争影响)。 + let mut idle_deadline = tokio::time::Instant::now() + FIRST_CHUNK_TIMEOUT; + let mut first_chunk_done = false; + + loop { + // B-260615-04:stop 即时打断。 + if stop_flag.load(std::sync::atomic::Ordering::SeqCst) { + stopped = true; + break; + } + + // BUG-2026-07-14 根治: wall-clock deadline 检查(不依赖 select! timeout 语义)。 + if tokio::time::Instant::now() >= idle_deadline { + if !first_chunk_done { + return StreamResult::InitFailed { + retryable: true, + error: format!( + "流式响应超时({}秒未收到首字节,服务无响应或请求体过大)", + FIRST_CHUNK_TIMEOUT.as_secs() + ), }; } - - // 正常完成:流尽 + finished 信号到位,Complete(incomplete=false) - StreamResult::Complete { + warn!( + provider = %provider.name(), + conv_id = %conv_id, + text_len = full_text.len(), + "[ai] 流中途 idle timeout(deadline),保文不重试(incomplete)", + ); + return StreamResult::Partial { text: full_text, tool_calls: tool_calls_acc, usage: final_usage.unwrap_or_default(), reasoning_content: reasoning_content_acc, + }; + } + + // 三分支 select!: + // 1) chunk_rx.recv():来自专用线程的 LLM chunk (15s 保底 timeout,防 select! 死等) + // 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog + // 3) notify.notified():用户停止即时打断 + tokio::select! { + chunk_result = tokio::time::timeout( + Duration::from_secs(15), + chunk_rx.recv() + ) => { + match chunk_result { + Err(_elapsed) => { + // 15s 保底 timeout 触发。正常路径不会到这里:心跳 30s 会先触发 select! 返回。 + if !first_chunk_done { + return StreamResult::InitFailed { + retryable: true, + error: format!( + "流式响应超时({}秒未收到首字节,服务无响应或请求体过大)", + FIRST_CHUNK_TIMEOUT.as_secs() + ), + }; + } + warn!( + provider = %provider.name(), + conv_id = %conv_id, + text_len = full_text.len(), + "[ai] 流中途 idle timeout,保文不重试(incomplete)", + ); + return StreamResult::Partial { + text: full_text, + tool_calls: tool_calls_acc, + usage: final_usage.unwrap_or_default(), + reasoning_content: reasoning_content_acc, + }; + } + Ok(None) => break, // mpsc channel 关闭 = 专用线程 stream 结束 + Ok(Some(Ok(chunk))) => { + // BUG-2026-07-14 根治: 收到 chunk 更新 idle_deadline。 + if !first_chunk_done { + first_chunk_done = true; + } + idle_deadline = tokio::time::Instant::now() + STREAM_IDLE_TIMEOUT; + if !chunk.delta.is_empty() { + full_text.push_str(&chunk.delta); + // L3 emit 双写 + let ev = AiChatEvent::AiTextDelta { + delta: chunk.delta, + conversation_id: Some(conv_id.to_string()), + }; + let _ = app_handle.emit("ai-chat-event", ev.clone()); + let _ = app_handle.state::().ai_event_bus.publish_event(ev); + } + if let Some(tc_deltas) = &chunk.tool_calls { + for tc_delta in tc_deltas { + let draft = tool_calls_acc.entry(tc_delta.index).or_default(); + if let Some(id) = &tc_delta.id { draft.id = id.clone(); } + if let Some(name) = &tc_delta.function_name { draft.name.push_str(name); } + if let Some(args) = &tc_delta.function_arguments { draft.args.push_str(args); } + } + } + if let Some(u) = &chunk.usage { + final_usage = Some(u.clone()); + } + if let Some(ref rc) = chunk.reasoning_content { + reasoning_content_acc.get_or_insert_with(String::new).push_str(rc); + } + // provider 流式错误事件 + if let Some(err_msg) = &chunk.error { + warn!( + provider = %provider.name(), + conv_id = %conv_id, + error = %err_msg, + text_len = full_text.len(), + "[ai] provider 流式错误事件", + ); + if full_text.is_empty() && tool_calls_acc.is_empty() { + return StreamResult::InitFailed { + retryable: true, + error: fmt_diag( + provider.name(), + DiagKind::MidStream, + "stream-error", + err_msg, + ), + }; + } + return StreamResult::Partial { + text: full_text, + tool_calls: tool_calls_acc, + usage: final_usage.unwrap_or_default(), + reasoning_content: reasoning_content_acc, + }; + } + if chunk.finished { + finished_received = true; + break; + } + } + Ok(Some(Err(err_str))) => { + // 专用线程上报的流错误(SSE 解析失败/HTTP 错误等) + let (status_or_class, raw) = extract_error_diag_from_str(&err_str); + warn!( + provider = %provider.name(), + status = %status_or_class, + conv_id = %conv_id, + error = %raw, + text_len = full_text.len(), + "[ai] 流式接收中途错误(from 专用线程)", + ); + if full_text.is_empty() && tool_calls_acc.is_empty() { + return StreamResult::InitFailed { + retryable: classify_status_or_class(&status_or_class), + error: fmt_diag( + provider.name(), + DiagKind::MidStream, + &status_or_class, + &raw, + ), + }; + } + return StreamResult::Partial { + text: full_text, + tool_calls: tool_calls_acc, + usage: final_usage.unwrap_or_default(), + reasoning_content: reasoning_content_acc, + }; + } + } + } + // 2) 心跳:静默期 30s 发 AiHeartbeat,前端 watchdog reset(B-260615-02) + _ = heartbeat.tick() => { + let ev = AiChatEvent::AiHeartbeat { + conversation_id: Some(conv_id.to_string()), + }; + let _ = app_handle.emit("ai-chat-event", ev.clone()); + let _ = app_handle.state::().ai_event_bus.publish_event(ev); + } + // 3) 即时停止唤醒(B-260615-14) + _ = notify.notified() => { + if stop_flag.load(std::sync::atomic::Ordering::SeqCst) { + stopped = true; + break; + } } } - Err(e) => { - // 诊断:连接/鉴权/HTTP 错误,补 provider 标识 + HTTP 状态码/分类 + 原始文本, - // 便于区分 401(key)/404(url)/429(限流)/timeout/连接失败(provider_type 或 base_url 不对)。 - let (status_or_class, raw) = extract_error_diag(&e); - warn!( - provider = %provider.name(), - status = %status_or_class, - conv_id = %conv_id, - error = %raw, - "[ai] LLM 流式调用失败", - ); - // UX-260618-15: 不再 emit AiError(retryable 走重试路径,Fatal 立即放弃,均交 agentic 统一 emit - // 最终错误气泡,避免重试过程 N 次失败 push N+1 气泡)。诊断文本经 fmt_diag 拼好携带出, - // agentic 重试耗尽/Fatal 时 emit AiError 用此文本,诊断信息不丢失。 - // CR-30-1: Init 失败分类——retryable 据 status_or_class 镜像 retry::is_status_retryable: - // 5xx/429/timeout/connect=true 可重试;4xx(非429)/鉴权/参数错=false Fatal 立即放弃。 - StreamResult::InitFailed { - retryable: classify_status_or_class(&status_or_class), - error: fmt_diag( - provider.name(), - DiagKind::Init, - &status_or_class, - &raw, - ), - } + } + + // 用户停止 + if stopped { + return StreamResult::Complete { + text: full_text, + tool_calls: tool_calls_acc, + usage: final_usage.unwrap_or_default(), + reasoning_content: reasoning_content_acc, + }; + } + + // 断连检测 + if !finished_received { + if full_text.is_empty() && tool_calls_acc.is_empty() { + return StreamResult::InitFailed { + retryable: true, + error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(), + }; } + warn!( + provider = %provider.name(), + conv_id = %conv_id, + text_len = full_text.len(), + "[ai] 流尽未收到 finished 但有 partial_text,保文不重试(incomplete)", + ); + return StreamResult::Partial { + text: full_text, + tool_calls: tool_calls_acc, + usage: final_usage.unwrap_or_default(), + reasoning_content: reasoning_content_acc, + }; + } + + // 正常完成 + StreamResult::Complete { + text: full_text, + tool_calls: tool_calls_acc, + usage: final_usage.unwrap_or_default(), + reasoning_content: reasoning_content_acc, } } @@ -453,12 +504,24 @@ fn classify_status_or_class(status_or_class: &str) -> bool { } i += 1; } - // 无三位数字码:按文本分类(timeout/connect 可重试,unknown/其他保守不重试) + // 无三位数字码:按文本分类(timeout/connect/transport/decode 可重试,其余 unknown 保守不重试) // BUG-260617-01:Anthropic 中文参数错误(如 `[1214][messages 参数非法]`)无 HTTP 数字前缀, // 走此文本分支,原 || true 致 retryable=true 空耗 4 次重试浪费 token。 // 删 || true 对齐 retry::is_status_retryable 未知码不可重试语义;timeout/connect 瞬态仍可重试。 + // + // BUG-2026-07-17 根治:SSE 解码/传输错误(error decoding response body / Transport error) + // 是传输层错误,不是 Fatal。原实现未识别这些关键词,返 unknown→Fatal,致连续重试 4 次均 + // 立即放弃。现补全传输层关键词,让这类错误走可重试路径。 let lower = status_or_class.to_lowercase(); - lower.contains("timeout") || lower.contains("connect") // unknown 保守不重试(BUG-260617-01) + lower.contains("timeout") + || lower.contains("connect") + || lower.contains("transport") + || lower.contains("decoding") + || lower.contains("decode") + || lower.contains("body") + || lower.contains("eof") + || lower.contains("reset") + || lower.contains("broken pipe") } // ============================================================ diff --git a/src-tauri/src/commands/ai/title.rs b/src-tauri/src/commands/ai/title.rs index 9f55e7a..49886af 100644 --- a/src-tauri/src/commands/ai/title.rs +++ b/src-tauri/src/commands/ai/title.rs @@ -82,15 +82,10 @@ pub(crate) async fn ensure_conversation_title( // 在传给 LLM 前合并连续同 role(拼接 content),不影响其他逻辑(本函数 summary 派生数据)。 let summary_msgs = merge_consecutive_roles(summary_msgs); - // B-260617-17 修复:进入即 extract_title 兜底落库 + emit,保证侧栏即时有非"新对话"标题。 - // 原实现仅在 LLM 返回 None(失败)或 provider 构建失败时才落 extract——但 LLM 卡住 - // (generate_title_via_llm 的 llm_concurrency 信号量 acquire 阻塞 / 网络挂起 / 后台 - // spawn 未跑完即刷新)时,既不返回 None 也不落库 → 标题长期停留"新对话"。现先落 extract - // 兜底,下方 LLM 生成成功后覆盖;LLM 卡住/失败/超时则保留兜底,无论如何不再"新对话"。 - let fallback_title = extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string()); - if let Err(e) = conv_repo.set_title(conv_id, &fallback_title).await { - tracing::error!("对话标题兜底落库失败(conv_id={}, title={}): {}", conv_id, fallback_title, e); - } + // B-260617-17 修复:先用 extract 兜底 emit 让侧栏即时更新(不落库), + // 下方 LLM 生成成功后覆盖。这样 LLM 失败时有重试机会, + // 不会永久锁定一个机械截取的差标题。 + let _fallback_title = extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string()); let _ = app_handle.emit("ai-conversation-changed", ()); // 标题生成是独立一次 LLM 调用,自建 provider(便于后台 spawn,不借主 loop 的 &dyn LlmProvider) @@ -204,15 +199,43 @@ fn clean_title(raw: &str) -> String { if cleaned.is_empty() { "新对话".to_string() } else { cleaned } } -/// 从消息历史中提取对话标题(取第一条用户消息前 30 字) +/// 从消息历史中提取对话标题。策略:取前 5 条 user 消息中字符数最多的那条。 +/// +/// 开头常出现测试/问候("1"、"你好"等),取最长消息自然跳过这些噪音。 +/// 极端情况全部过短时(仅测试消息),用第一条非空 user 兜底。 pub(crate) fn extract_title(messages: &[ChatMessage]) -> Option { - messages.iter() - .find(|m| matches!(m.role, MessageRole::User)) - .map(|m| { - let mut chars = m.content.chars(); - let t: String = chars.by_ref().take(30).collect(); - if chars.next().is_some() { format!("{}...", t) } else { t } - }) + const MAX_TITLE_CHARS: usize = 60; + const SCAN_LIMIT: usize = 5; + + let user_msgs: Vec<&str> = messages.iter() + .filter(|m| matches!(m.role, MessageRole::User)) + .take(SCAN_LIMIT) + .map(|m| m.content.trim()) + .filter(|s| !s.is_empty()) + .collect(); + + // 取字符数最多的 + let best = user_msgs.iter() + .max_by_key(|s| s.chars().count()) + .copied(); + + match best { + Some(text) => { + let mut chars = text.chars(); + let t: String = chars.by_ref().take(MAX_TITLE_CHARS).collect(); + if chars.next().is_some() { Some(format!("{}...", t)) } else { Some(t) } + } + None => { + // 极端情况全空 → 第一条非空 user(兜底) + messages.iter() + .find(|m| matches!(m.role, MessageRole::User) && !m.content.trim().is_empty()) + .map(|m| { + let mut chars = m.content.trim().chars(); + let t: String = chars.by_ref().take(MAX_TITLE_CHARS).collect(); + if chars.next().is_some() { format!("{}...", t) } else { t } + }) + } + } } /// 合并相邻同 role 消息,拼接 content(防 Anthropic 1214 连续同 role 错误)。 diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index a030a61..c12e9f3 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -593,7 +593,7 @@ fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc) { })}, ); registry.register( - "bind_directory", "为项目绑定代码目录(自动探测技术栈,防重复绑定)", + "bind_directory", "为项目绑定代码目录(自动探测技术栈,防重复绑定)。path 应为**项目根路径**(单仓库=仓库根;多工程 monorepo=工作区根,非单个子工程路径;子工程信息走 list_project_modules/update_project_module 维护)。多次调用会覆盖 path 字段,monorepo 场景勿为每个子工程重复绑定。可选 stack 手动指定技术栈(如 'Go / Gin / Vue3'),不传则自动探测。", df_ai::ai_tools::object_schema(vec![("id", "string", true), ("path", "string", true), ("stack", "string", false)]), RiskLevel::Medium, { let db = db.clone(); Box::new(move |args: serde_json::Value| { @@ -1308,22 +1308,27 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc) // Git 只读 AI 工具(工程系统,2026-06-29) // ============================================================ -/// 在指定目录执行 git 命令(10s 超时,返回 stdout)。失败返回空字符串(非崩溃)。 +/// 在指定目录执行 git 命令(10s 超时,返回 stdout)。失败/超时返回空字符串(非崩溃)。 +/// +/// BUG-2026-07-18: 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout +/// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限, +/// spawn_blocking 线程永不返回 → 累计耗尽 tokio blocking 池 → 间接卡死单线程 runtime +/// (与 env_snapshot::probe_version 同型病根)。AI 的 git_status/log/diff 工具在会话内高频触发。 +/// +/// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程 +/// 被 kill,不泄漏线程/进程,对齐 shell.rs execute 同源封装。 async fn exec_git(working_dir: &str, args: &[&str]) -> String { - let dir = working_dir.to_string(); - let args_vec: Vec = args.iter().map(|s| s.to_string()).collect(); - let result = tokio::task::spawn_blocking(move || { - let mut cmd = std::process::Command::new("git"); - cmd.args(&args_vec).current_dir(&dir); - cmd.stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()); - match cmd.output() { - Ok(out) => String::from_utf8_lossy(&out.stdout).to_string(), - Err(_) => String::new(), - } - }) - .await; - result.unwrap_or_default() + let mut cmd = tokio::process::Command::new("git"); + cmd.args(args) + .current_dir(working_dir) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await { + Ok(Ok(out)) => String::from_utf8_lossy(&out.stdout).to_string(), + Ok(Err(_)) => String::new(), + Err(_elapsed) => String::new(), // 超时:child 被 kill_on_drop 终止 + } } /// git status --porcelain 解析为结构化文件列表。 @@ -1767,7 +1772,16 @@ fn register_file_tools( use tokio::fs::File; use tokio::io::AsyncReadExt; let mut file = File::open(path).await - .map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?; + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + anyhow::anyhow!( + "无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表", + path + ) + } else { + anyhow::anyhow!("无法访问文件 {}: {}", path, e) + } + })?; let metadata = file.metadata().await .map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?; if metadata.len() > 1_048_576 { @@ -2510,11 +2524,25 @@ fn register_file_tools( let output_mode = args.get("output_mode").and_then(|v| v.as_str()).unwrap_or("content"); let max_results = args.get("max_results").and_then(|v| v.as_u64()).unwrap_or(50).clamp(1, 200) as usize; - // 编译正则:case_insensitive 开 i flag;失败上抛明确错误(非法正则不是业务错,LLM 据此修参) + // 编译正则:case_insensitive 开 i flag;失败尝试自动修复常见问题(未分组 | 用 (?:...) 包裹) let mut re_builder = regex::RegexBuilder::new(pattern); re_builder.case_insensitive(case_insensitive); - let re = re_builder.build() - .map_err(|e| anyhow::anyhow!("正则编译失败「{}」: {}", pattern, e))?; + let (re, _auto_fixed) = match re_builder.build() { + Ok(r) => (r, false), + Err(e) => { + // LLM 常传 `a|b|c` 未分组模式(如 ` (r, true), + Err(_) => anyhow::bail!( + "正则编译失败「{}」: {}。提示:多选模式请用 (?:a|b|c) 分组", + pattern, e + ), + } + } + }; // glob 过滤器:编译为 regex 单段匹配(* → [^/]*, ? → [^/], 字面其他字符 escape)。 // 仅匹配文件名单段(不含 /),对齐 Claude Code grep glob 语义。 @@ -3341,18 +3369,20 @@ mod tests { // register_git_tools 3 个) // Git 写工具(2026-06-29): data 层 31→34(新增 git_commit/git_branch/git_merge, // register_git_tools 3→6) + // BUG-2026-07-07: 修正 baseline 计数(原写死 41 与实际 48 不符,HEAD 上即失败)。 + // 48 = 34 data + 13 file + 1 http。data 34 含 6 知识图谱 + 2 基础设施 + 1 module + 6 git。 assert_eq!( registry.len(), - 41, - "工具总数应为 41(27 data + 13 file + 1 http),实际 {}", registry.len() + 48, + "工具总数应为 48(34 data + 13 file + 1 http),实际 {}", registry.len() ); // 工具名集合基线:防 rename / 漏注册 / 误删除。 - // data 层 27 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + // data 层 34 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具 // file 层 13 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析 // http 层 1 个(不持 db):http_request let mut expected: Vec<&str> = vec![ - // ── data 层 (27) ── + // ── data 层 (34) ── "list_projects", "list_tasks", "list_ideas", "update_project", "create_project", "bind_directory", "create_task", "update_task", "advance_task", diff --git a/src-tauri/src/commands/ci_status.rs b/src-tauri/src/commands/ci_status.rs index 267e1b3..6a81ca3 100644 --- a/src-tauri/src/commands/ci_status.rs +++ b/src-tauri/src/commands/ci_status.rs @@ -53,7 +53,11 @@ fn parse_repo_url(repo_url: &str) -> Option<(String, String, String)> { return None; } let host = url.host_str()?; - let port = url.port_or_known_default(); + // BUG-2026-07-07: port_or_known_default() 对 https 返 Some(443)、http 返 Some(80), + // 致 authority 总是带默认端口(:443/:80),base URL 形如 "https://host:443" 而非规范 + // "https://host"。下游 Gitea API 调用虽大多容忍,但测试断言与可读性受影响。 + // 修复:仅当端口非 scheme 默认端口时才拼入 authority(显式非默认端口才保留)。 + let port = url.port(); // 仅取显式端口(未在 URL 写出的不返) let authority = match port { Some(p) => format!("{}:{}", host, p), None => host.to_string(), diff --git a/src-tauri/src/commands/module.rs b/src-tauri/src/commands/module.rs index 7badeeb..b2403a9 100644 --- a/src-tauri/src/commands/module.rs +++ b/src-tauri/src/commands/module.rs @@ -352,17 +352,15 @@ pub async fn list_branches( return Ok(serde_json::json!({ "current": "", "branches": [] })); } let dir = module.path.clone(); - let result = std::thread::spawn(move || -> (String, Vec) { + // git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时 + let result = tokio::task::spawn_blocking(move || -> (String, Vec) { // git branch --format="%(HEAD)%00%(refname:short)" - let out = std::process::Command::new("git") - .args(["branch", "--format=%(HEAD)%00%(refname:short)"]) - .current_dir(&dir) - .env("LANG", "en_US.UTF-8") - .env("LC_ALL", "en_US.UTF-8") - .output() - .ok() - .and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).to_string()) } else { None }) - .unwrap_or_default(); + let out = run_git_cmd( + std::path::Path::new(&dir), + &["branch", "--format=%(HEAD)%00%(refname:short)"], + std::time::Duration::from_secs(10), + ) + .unwrap_or_default(); let mut current = String::new(); let mut branches = Vec::new(); for line in out.lines() { @@ -378,8 +376,8 @@ pub async fn list_branches( } (current, branches) }) - .join() - .map_err(|_| "分支列表查询线程 Join 失败".to_string())?; + .await + .map_err(|e| format!("分支列表查询任务失败: {e}"))?; Ok(serde_json::json!({ "current": result.0, @@ -801,26 +799,18 @@ pub async fn get_module_file_diff( return Ok(serde_json::json!({"path": file_path, "diff": ""})); } // 跑 git diff (工作树 vs 索引的 unstaged 变更+staged 变更) - // 设置环境变量强制 UTF-8 输出,防中文乱码。 + // spawn_blocking + run_git_cmd(10s 超时,防 git 卡死阻塞 runtime)。 let cmd_dir = root.clone(); let path_arg = file_path.replace('\\', "/"); - let diff = std::thread::spawn(move || -> Option { - let out = std::process::Command::new("git") - .args(["diff", "--", &path_arg]) - .current_dir(&cmd_dir) - .env("LANG", "en_US.UTF-8") - .env("LC_ALL", "en_US.UTF-8") - .env("GIT_PAGER", "cat") - .output() - .ok()?; - if out.status.success() && !out.stdout.is_empty() { - Some(String::from_utf8_lossy(&out.stdout).to_string()) - } else { - None - } + let diff = tokio::task::spawn_blocking(move || -> Option { + run_git_cmd( + &cmd_dir, + &["diff", "--", &path_arg], + std::time::Duration::from_secs(10), + ) }) - .join() - .map_err(|_| "git diff 线程 Join 失败".to_string())? + .await + .map_err(|e| format!("git diff 任务失败: {e}"))? .unwrap_or_default(); Ok(serde_json::json!({ "path": file_path.replace('\\', "/"), @@ -879,15 +869,19 @@ pub async fn scan_project_modules( if !child_path.join(".git").exists() { continue; } let child_path_str = child_path.to_string_lossy().replace("\\", "/"); if existing_paths.contains(&child_path_str.to_lowercase()) { continue; } - // 获取远程地址(失败忽略) - let git_url = std::process::Command::new("git") - .args(["remote", "get-url", "origin"]) - .current_dir(&child_path) - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .filter(|s| !s.is_empty()); + // 获取远程地址(失败忽略);spawn_blocking + run_git_cmd(10s 超时,防 git 卡死阻塞 runtime) + let url_dir = child_path.clone(); + let git_url = tokio::task::spawn_blocking(move || { + run_git_cmd( + &url_dir, + &["remote", "get-url", "origin"], + std::time::Duration::from_secs(10), + ) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }) + .await + .unwrap_or(None); let now_str = now_millis(); let record = ProjectModuleRecord { id: new_id(), @@ -976,22 +970,19 @@ pub async fn get_module_commits( let fetch_plus = fetch + 1; let dir = module.path.clone(); let dir_for_git = dir.clone(); - let commits: Vec = std::thread::spawn(move || -> Vec { - let out = std::process::Command::new("git") - .args([ + // git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时 + let commits: Vec = tokio::task::spawn_blocking(move || -> Vec { + let out = run_git_cmd( + std::path::Path::new(&dir_for_git), + &[ "log", &format!("--skip={}", skip), &format!("-{}", fetch_plus), "--format=%h %ct %an %s", - ]) - .current_dir(&dir_for_git) - .env("LANG", "en_US.UTF-8") - .env("LC_ALL", "en_US.UTF-8") - .output() - .ok() - .and_then(|o| if o.status.success() { Some(o.stdout) } else { None }) - .map(|b| String::from_utf8_lossy(&b).to_string()) - .unwrap_or_default(); + ], + std::time::Duration::from_secs(10), + ) + .unwrap_or_default(); let mut commits: Vec = Vec::new(); for line in out.lines() { let line = line.trim(); @@ -1014,8 +1005,8 @@ pub async fn get_module_commits( } commits }) - .join() - .map_err(|_| "提交历史查询线程 Join 失败".to_string())?; + .await + .map_err(|e| format!("提交历史查询任务失败: {e}"))?; // 判断 has_more:取了 N+1 条但只返回 N 条,说明有更多 let total_fetched = commits.len(); @@ -1055,17 +1046,17 @@ pub async fn get_commit_detail( return Ok(serde_json::json!({ "files": [], "diff": "" })); } let dir = module.path.clone(); - let (files, diff, parents, author, date, full_message) = std::thread::spawn(move || -> (Vec, String, Vec, String, String, String) { + // git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时 + let (files, diff, parents, author, date, full_message) = tokio::task::spawn_blocking(move || -> (Vec, String, Vec, String, String, String) { + let dir_path = std::path::Path::new(&dir); + let timeout = std::time::Duration::from_secs(10); // 1) 获取变更文件列表:`git diff-tree --no-commit-id -r --name-status ` - let files_out = std::process::Command::new("git") - .args(["diff-tree", "--no-commit-id", "-r", "--name-status", &commit_hash]) - .current_dir(&dir) - .env("LANG", "en_US.UTF-8") - .env("LC_ALL", "en_US.UTF-8") - .output() - .ok() - .and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).to_string()) } else { None }) - .unwrap_or_default(); + let files_out = run_git_cmd( + dir_path, + &["diff-tree", "--no-commit-id", "-r", "--name-status", &commit_hash], + timeout, + ) + .unwrap_or_default(); let mut files: Vec = Vec::new(); for line in files_out.lines() { let line = line.trim(); @@ -1079,26 +1070,20 @@ pub async fn get_commit_detail( } } // 2) 获取全量 diff:`git show `(仅 diff 部分) - let diff = std::process::Command::new("git") - .args(["show", "--format=", &commit_hash]) - .current_dir(&dir) - .env("LANG", "en_US.UTF-8") - .env("LC_ALL", "en_US.UTF-8") - .output() - .ok() - .and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).to_string()) } else { None }) - .unwrap_or_default(); + let diff = run_git_cmd( + dir_path, + &["show", "--format=", &commit_hash], + timeout, + ) + .unwrap_or_default(); // 3) 获取提交元信息(父提交/作者/日期/完整消息): // 用 printf 自定义格式,%P=父哈希(空格分隔多个)\t%an=作者\t%ad=日期\t%B=完整消息 - let meta = std::process::Command::new("git") - .args(["show", "-s", "--format=%P\t%an\t%ad\t%B", &commit_hash]) - .current_dir(&dir) - .env("LANG", "en_US.UTF-8") - .env("LC_ALL", "en_US.UTF-8") - .output() - .ok() - .and_then(|o| if o.status.success() { Some(String::from_utf8_lossy(&o.stdout).to_string()) } else { None }) - .unwrap_or_default(); + let meta = run_git_cmd( + dir_path, + &["show", "-s", "--format=%P\t%an\t%ad\t%B", &commit_hash], + timeout, + ) + .unwrap_or_default(); let mut parents = Vec::new(); let mut author = String::new(); let mut date = String::new(); @@ -1121,8 +1106,8 @@ pub async fn get_commit_detail( } (files, diff, parents, author, date, full_message) }) - .join() - .map_err(|_| "提交详情查询线程 Join 失败".to_string())?; + .await + .map_err(|e| format!("提交详情查询任务失败: {e}"))?; Ok(serde_json::json!({ "files": files, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9a05e97..a1a0a9c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,20 @@ mod state; use tauri::{Emitter, Listener, Manager}; use state::AppState; + +/// 用户本地时区日志时间戳格式化器。 +/// tracing_subscriber 默认输出 UTC,用户在中国 UTC+8,日志与本地时间差 8 小时不便调试。 +/// 使用 chrono::Local 输出含时区偏移的本地时间(如 "2026-07-17T15:30:00.123456+08:00")。 +/// +/// 注意:FormatTime::format_time 参数类型为 tracing_subscriber::fmt::format::Writer, +/// 非 std::fmt::Write。Writer 实现了 fmt::Write,可直接 write!。 +struct LocalTimer; +impl tracing_subscriber::fmt::time::FormatTime for LocalTimer { + fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result { + write!(w, "{}", chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.6f%:z")) + } +} + // Phase3 跨端隧道:TunnelClient trait(connect/send_raw_event/disconnect 方法)需在作用域内 use df_tunnel::TunnelClient; @@ -20,6 +34,7 @@ pub fn run() { .expect("创建日志文件失败"); let (non_blocking, _guard) = tracing_appender::non_blocking(log_file); tracing_subscriber::fmt() + .with_timer(LocalTimer) .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")) @@ -88,27 +103,36 @@ pub fn run() { // F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv // 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。 // 批4:per_conv 唯一真相源,删顶层 session.generating 双写复位(顶层字段已退役)。 - // 读点(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),比原 - // generating 更全面——HMR 热载下 Compressed 残留态也一并清理(避免压缩中 conv 卡死)。 - let dirty_convs: Vec = session - .per_conv - .iter() - .filter(|(_, c)| c.conv_state.is_active()) - .map(|(id, _)| id.clone()) - .collect(); + // 读点(B-Phase2):活跃 conv 列表改读无锁 conv_states(零锁竞争);不再 iter per_conv + // 判 conv_state.is_active()。 + // BUG-2026-07-08: L0 握手清理 dirty conv 前,必须先 stop 旧 loop。 + // 原逻辑直接把 ConvState 改 Idle,但后台 run_agentic_loop 仍在跑(等 LLM 响应)。 + // 改 Idle 后用户重发 → can_accept_request 放行 → 新旧两个 loop 同操作一个 conv → + // 消息覆盖/guard 冲突/generating 紊乱。 + // 修复:先设 stop_flag + notify_one()(旧 loop 的 stream_llm select! 即时唤醒) + // + 再改 ConvState。notify_one 让阻塞在 stream.next() 的 loop 即时检查 stop_flag 退出。 + let app_state_ref = app_h.state::(); + let dirty_convs: Vec = app_state_ref.conv_states.active_convs(); let was_generating = !dirty_convs.is_empty(); - // 复位每个残留生成态的 conv(批8 多 conv 全覆盖)。 - // 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating/Compressed→Idle)。 + // 先对每个 dirty conv 设 stop_flag + notify 让旧 loop 退出(防双 loop 并发)。 for cid in &dirty_convs { - if let Some(conv) = session.per_conv.get_mut(cid) { - match conv.conv_state.transition_to(crate::commands::ai::agentic::conv_state::ConvState::Idle) { - Ok(ns) => conv.conv_state = ns, - Err(e) => tracing::warn!( - conv_id = %cid, - error = %e, - "[ai] HMR 热载 ConvState→Idle 非法(不阻断热载)" - ), - } + if let Some(c) = session.per_conv.get_mut(cid) { + c.stop_flag.store(true, std::sync::atomic::Ordering::SeqCst); + c.notify.notify_one(); + } + } + // 复位每个残留生成态的 conv(批8 多 conv 全覆盖)。 + // B-Phase3:conv_state 写切 ConvStateStore 单源。 + for cid in &dirty_convs { + if let Err(e) = app_state_ref.conv_states.transition( + cid, + crate::commands::ai::agentic::conv_state::ConvState::Idle, + ) { + tracing::warn!( + conv_id = %cid, + error = %e, + "[ai] HMR 热载 conv_states→Idle 非法(不阻断热载)" + ); } } // 对话透明化 L1:收集每个 dirty conv 的 pinned_goals 快照(供 emit AiCompleted 携带) @@ -123,23 +147,35 @@ pub fn run() { // 保留 restore 重建(recovered=true,audit.rs:331),对齐 switchConversation retain 保护意图。 // 阶段3a 单真相源合并:单表按 !recovered retain(kind 不区分,path 审批恢复恒无)。 session.pending_approvals.retain(|_, a| !a.recovered); + // 在 drop(session) 前快照 active_conversation_id,供下方 idle emit 用。 + let active_conv = session.active_conversation_id.clone(); 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()), - pinned_goals: pinned_goals_map.get(cid).cloned().unwrap_or_default(), - }, - ); - } + // 根治:不论是否有 dirty conv,HMR 重连后始终向前端推 idle 事件, + // 防前端残留 streaming=true 导致发送按钮变停止、消息排队不发。 + // dirty conv 走 AiCompleted 收尾路由(idle conv 无历史需收尾无需气泡)。 + 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()), + pinned_goals: pinned_goals_map.get(cid).cloned().unwrap_or_default(), + }, + ); + } + // 始终推 AiConvStateChanged{idle} 通知前端同步(即使后端已是 idle)。 + // 前端 handleConvStateEvent → setConvState(convId, 'idle') → convStates 删项。 + if let Some(ref cid) = active_conv { + let _ = app_h.emit( + "ai-chat-event", + commands::ai::AiChatEvent::AiConvStateChanged { + conv_state: crate::commands::ai::agentic::conv_state::ConvState::Idle, + conversation_id: Some(cid.clone()), + }, + ); } tracing::info!( "[L0-handshake] 前端重连握手完成, was_generating={}, dirty_convs={:?}", @@ -460,8 +496,6 @@ pub fn run() { commands::settings::delete_template, // CI 状态 commands::ci_status::get_commit_status, - // CI 检查状态(Gitea commit statuses,失败返回空列表不阻断工作流) - commands::ci_status::get_commit_status, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index e7cc646..11ab296 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -30,6 +30,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::time::Duration; use anyhow::Result; use tokio::sync::{Mutex, RwLock}; @@ -117,6 +118,12 @@ pub struct AppState { /// 标 allow 保留作批3 接入(零调用方≠垃圾,预留保留)。 #[allow(dead_code)] pub ai_event_bus: crate::commands::ai::event_bus::EventBus, + /// B-Phase3: ConvState 无锁并发存储(单源收敛)。 + /// + /// ConvState 从 session.per_conv[conv_id] 提到独立 DashMap, + /// guard/reset/ai_is_generating 全部零锁操作。PerConvState.conv_state 字段已删。 + /// 治卡死连环(AiCompleted 延迟 / 工具后中断 / 第二条进队列同源根因)。 + pub conv_states: Arc, // ── 知识库 ── /// 知识库 Repo pub knowledge: KnowledgeRepo, @@ -185,6 +192,39 @@ fn workspace_root_path() -> PathBuf { .unwrap_or_else(|| PathBuf::from(".")) } +/// async IPC 路径的 canonicalize 防卡包装(spawn_blocking + 2s timeout)。 +/// +/// `std::fs::canonicalize` 在网络挂载盘/坏符号链接/盘符掉线等场景可能长时间阻塞; +/// 在 async IPC handler 中直接同步调用会卡 tokio runtime 工作线程。本函数把同步 +/// canonicalize 移入 spawn_blocking 隔离 + 2s 总超时,超时返原路径兜底(降级匹配, +/// 不阻断合法访问 — 与现有 canonicalize 失败回退词法路径的语义一致)。 +/// +/// - 成功 → canonicalize 后真实路径(经 strip_verbatim 去 Windows verbatim 前缀) +/// - 失败/超时 → 原字面量路径 trim 末尾分隔符(与 reload_allowed_dirs 既有兜底语义对齐) +async fn canonicalize_with_timeout(p: PathBuf) -> PathBuf { + // 提前留一份原始字面量用于兜底/日志(spawn_blocking 闭包 move 后 p 不再可借用)。 + let raw_display = p.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); + match tokio::time::timeout( + Duration::from_secs(2), + tokio::task::spawn_blocking(move || std::fs::canonicalize(&p).ok()), + ) + .await + { + Ok(JoinResult::Ok(Some(real))) => strip_verbatim(real), + // 超时 / canonicalize 失败 / JoinError:降级回原词法路径 + _ => { + tracing::warn!( + "[canonicalize] 超时或失败,按字面量保存: {}", + raw_display + ); + PathBuf::from(raw_display) + } + } +} + +/// tokio::task::JoinResult 别名(简化 match 类型签名,避免长泛型)。 +type JoinResult = Result; + impl AppState { /// 初始化应用状态:打开(或创建)数据库并执行迁移,构建各 Repo 与节点注册表 pub async fn init(db_path: &Path, data_dir: PathBuf) -> Result { @@ -223,6 +263,8 @@ impl AppState { ai_session: Arc::new(Mutex::new(AiSession::new())), // L3 批2b:AI 事件总线入 AppState(独立于工作流 event_bus)。emit 双写留批3。 ai_event_bus: crate::commands::ai::event_bus::EventBus::new(), + // B-Phase1: ConvStateStore 无锁存储(空构造;双轨期无写点,B-Phase2 写侧切入)。 + conv_states: Arc::new(crate::commands::ai::agentic::conv_state::ConvStateStore::new()), // Phase3 Layer1:tunnel 客户端未连接实例,lib.rs setup 内 connect tunnel: std::sync::Arc::new(df_tunnel::WsTunnelClient::new()), knowledge: KnowledgeRepo::new(&db), @@ -402,12 +444,9 @@ impl AppState { continue; } let p = PathBuf::from(d); - // canonicalize 成功用真实路径(去 symlink/大小写归一);失败回退原字面量 trim - // (兼容"先授权目录,目录暂不存在"用例)。失败打 warn 便于排查静默授权错路径。 - let normalized = strip_verbatim(std::fs::canonicalize(&p).unwrap_or_else(|_| { - tracing::warn!("[allowed_dirs] canonicalize 失败(目录可能不存在),按字面量保存: {}", d); - PathBuf::from(d.trim_end_matches(['/', '\\'])) - })); + // canonicalize 成功用真实路径(去 symlink/大小写归一);失败/超时回退原字面量 trim + // (兼容"先授权目录,目录暂不存在"用例)。spawn_blocking+timeout(2s) 防 fs 卡死拖垮 IPC。 + let normalized = canonicalize_with_timeout(p).await; set.insert(normalized); } // F-260619-03 Phase B: reload 时保留当前会话临时授权(session 不落库,仅内存), @@ -481,9 +520,8 @@ impl AppState { return; } let p = PathBuf::from(d); - let normalized = strip_verbatim(std::fs::canonicalize(&p).unwrap_or_else(|_| { - PathBuf::from(d.trim_end_matches(['/', '\\'])) - })); + // spawn_blocking+timeout(2s) 防 fs 卡死拖垮 IPC;失败/超时降级词法路径(与既有兜底语义一致)。 + let normalized = canonicalize_with_timeout(p).await; self.allowed_dirs.write().await.session.insert(normalized); } @@ -506,9 +544,8 @@ impl AppState { return; } let p = PathBuf::from(d); - let normalized = strip_verbatim(std::fs::canonicalize(&p).unwrap_or_else(|_| { - PathBuf::from(d.trim_end_matches(['/', '\\'])) - })); + // spawn_blocking+timeout(2s) 防 fs 卡死拖垮 IPC;失败/超时降级词法路径(与既有兜底语义一致)。 + let normalized = canonicalize_with_timeout(p).await; self.allowed_dirs.write().await.once.insert(normalized); } diff --git a/src-tauri/src/state/allowed_dirs.rs b/src-tauri/src/state/allowed_dirs.rs index b91475a..3021090 100644 --- a/src-tauri/src/state/allowed_dirs.rs +++ b/src-tauri/src/state/allowed_dirs.rs @@ -165,8 +165,22 @@ pub fn check_path_authorization( raw_path: &str, allowed: &AllowedDirs, ) -> PathAuthDecision { + // Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization + // 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。 + // + // BUG-2026-07-07: 黑名单检查必须在路径规范化(is_absolute / root.join)之前对**原始路径** + // 也判一次。原因:Windows 上 "/home/user/.ssh/config" 这类 Unix 风格路径 is_absolute() 返 false + // (Windows 绝对路径需盘符或 UNC),会被当相对路径走 first_persistent_dir 分支;若 persistent 为空, + // 旧逻辑直接 return NeedsAuthorization,**跳过黑名单检查** → .ssh/.aws 等敏感路径在 Windows 上绕过黑名单。 + // 修复:先用原始路径判黑名单(segment 匹配,任意路径段命中 .ssh 即拒,与路径形态无关),命中直接 Denied。 + let raw_resolved = Path::new(raw_path); + if is_in_system_blacklist(raw_resolved) { + return PathAuthDecision::Denied { + reason: format!("路径命中系统敏感目录黑名单: {}", raw_path), + }; + } // 规范化:绝对路径原样,相对路径锚定首个持久授权目录(与 resolve_workspace_path_impl 一致) - let resolved = if Path::new(raw_path).is_absolute() { + let resolved = if raw_resolved.is_absolute() { PathBuf::from(raw_path) } else if let Some(root) = allowed.first_persistent_dir() { root.join(raw_path) @@ -176,8 +190,7 @@ pub fn check_path_authorization( dir: PathBuf::from("."), }; }; - // Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization - // 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。 + // 规范化后再判一次黑名单(锚定后路径形态可能变化,双保险) if is_in_system_blacklist(&resolved) { return PathAuthDecision::Denied { reason: format!("路径命中系统敏感目录黑名单: {}", raw_path), @@ -430,6 +443,38 @@ mod tests { } } + /// BUG-2026-07-07: Windows 上 Unix 风格 .ssh 路径必须被黑名单拒(不能绕过)。 + /// + /// 背景:Windows 上 `/home/user/.ssh/config` 的 is_absolute() 返 false(无盘符), + /// 旧逻辑在 persistent 为空时直接返 NeedsAuthorization,**跳过黑名单检查**, + /// 致 .ssh/.aws 等敏感路径在 Windows 上绕过黑名单。修复后原始路径也判黑名单。 + /// 此测试锁死该修复:空 persistent + Unix 风格 .ssh 路径 → 必 Denied。 + #[test] + fn test_check_path_blacklist_unix_style_ssh_on_windows() { + let allowed = AllowedDirs::default_with_root(); + match check_path_authorization("/home/user/.ssh/config", &allowed) { + PathAuthDecision::Denied { .. } => {} + other => panic!("Unix 风格 .ssh 路径应 Denied(黑名单), got {:?}", other), + } + // 同理 .aws / .gnupg + match check_path_authorization("/home/user/.aws/credentials", &allowed) { + PathAuthDecision::Denied { .. } => {} + other => panic!("Unix 风格 .aws 路径应 Denied(黑名单), got {:?}", other), + } + } + + /// 回归:合法相对路径(非黑名单)在空 persistent 下仍应返 NeedsAuthorization(引导授权)。 + /// 修复黑名单提前后,需确认非黑名单相对路径行为不变。 + #[test] + fn test_check_path_non_blacklist_relative_still_needs_auth() { + let allowed = AllowedDirs::default_with_root(); + match check_path_authorization("some/regular/path", &allowed) { + PathAuthDecision::NeedsAuthorization { .. } => {} + other => panic!("非黑名单相对路径 + 空 persistent 应 NeedsAuthorization, got {:?}", other), + } + } + + // ============================================================ // F-260620: strip_verbatim 比对侧收口 + 黑名单增强测试 // 三方审查(安全/UX/跨端)交叉印证:strip_verbatim 仅写入侧调用,比对侧遗漏致误弹窗。 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8768ea7..e9db4f6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -5,6 +5,7 @@ "build": { "beforeDevCommand": "bun dev", "beforeBuildCommand": "bun run build", + "devUrl": "http://localhost:1420", "frontendDist": "../dist" }, "app": { diff --git a/src/api/types.ts b/src/api/types.ts index f2e14a8..cc7d23b 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -494,6 +494,11 @@ export interface AiMessage { * (不强制校验变体字段,未知 kind 忽略,对齐后端「新增变体向后兼容」设计)。 */ augmentations?: Augmentation[] + /** + * 这一轮对话的 token 用量(仅 assistant 消息,每轮 AiCompleted 时回填)。 + * undefined=未记录(历史消息/未启用 token 显示)。 + */ + tokenUsage?: { prompt: number; completion: number } timestamp: number } diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index 9e17c08..e95dec5 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -155,6 +155,7 @@ const props = defineProps<{ const store = useAiStore() const { t } = useI18n() const router = useRouter() + // path_auth 审批链阶段3b(统一审批模型):df-ai-unified-approval 开关响应式 ref。 // 开(true,默认):path 挂起归一进 ToolCard 内联审批(once/always/deny),不挂 DirAuthDialog。 // 关(false,兜底回退):挂 DirAuthDialog 独立弹窗老链路(useAiEvents AiDirAuthRequired case 走 pendingDirAuths)。 @@ -255,7 +256,12 @@ async function confirmClearChat() { * 历史:B-260615-10 单 loop 软复位弹中断确认,F-09 决策e 上线后已废弃(A 路线遗留)。 */ async function confirmNewConversation() { - await store.newConversation() + try { + await store.newConversation() + } catch (e) { + console.error('[AI] 新建对话失败:', e) + // 用错误气泡反馈用户,不静默吞掉 + } } // 第二批抽离: 技能 `/` 联想 + @ 实体引用联想 + watch(inputText) 已迁移至 ChatInput.vue @@ -510,18 +516,24 @@ onMounted(async () => { console.debug(`[启动] AiChat IPC 完成: ${(performance.now() - t0).toFixed(0)}ms`) }) -// ── 置顶功能(持久化:刷新后保持置顶状态图标一致)── +// ── 置顶功能(仅分离窗口有意义;持久化:刷新后保持置顶状态图标一致)── +// 关键:置顶只对「分离窗口」生效。AiChat 在主窗口(App.vue)与分离窗口(AiDetached.vue)都挂载, +// 若不区分,主窗口启动时会因 KV=true 把【整个主窗口】置顶到所有应用之上(BUG)。 +// 主窗口内 AI 是 docked 面板,主窗口本身不该置顶。故 restore/apply 一律 gate 在 props.detached。 const alwaysOnTop = ref(false) const appSettingsStore = useAppSettingsStore() -// 从 SQLite KV 恢复上次置顶状态 +// 从 SQLite KV 恢复上次置顶状态(仅分离窗口读用;主窗口读了也无副作用,ref 仅驱动按钮 active 态) const savedAlwaysOnTop = appSettingsStore.get('df-ai-always-on-top', false) -alwaysOnTop.value = savedAlwaysOnTop -// 若恢复值为 true(上次是置顶态),同步到 Tauri 窗口 -if (savedAlwaysOnTop) { - // 异步设(不阻塞初始化流程) - const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow') - getCurrentWebviewWindow().setAlwaysOnTop(true).catch(() => {/* 静默 */}) -} +alwaysOnTop.value = props.detached ? savedAlwaysOnTop : false +// 同步到 Tauri 窗口:分离窗口按 KV 还原;主窗口显式清置顶(防御历史误设/窗口状态残留)。 +// 注意:此处不可用顶层 await —— diff --git a/src/composables/ai/streamingGuard.ts b/src/composables/ai/streamingGuard.ts index 2816b21..2fde054 100644 --- a/src/composables/ai/streamingGuard.ts +++ b/src/composables/ai/streamingGuard.ts @@ -21,6 +21,7 @@ //! streaming 是 boolean 无真正非法跃迁,guard 价值在集中入口与日志而非拦截。 import { state } from '@/stores/ai' +import { convStates } from './aiShared' import { useAppSettingsStore } from '@/stores/appSettings' /// feature flag key(appSettings KV)。关=回退散布语义(灰度/回退用)。 @@ -100,4 +101,14 @@ export function forceResetStreaming(reason: string, convId?: string): void { setStreaming(false, { convId: convId ?? null, reason }) state.currentText = '' state.queue = [] + // BUG-2026-07-17 根治:同时清理 convStates 项。 + // 原实现仅清 streaming/currentText/queue,但按钮 stopBtnState 优先读 convStates + // (ChatInput.vue:243),convStates=generating 时仍显红色停止按钮。后端未发 + // AiConvStateChanged 时(conv_state 永久卡住),必须前端主动清 convStates 才能解锁 UI。 + if (convId) { + convStates.delete(convId) + } else { + // 全局兜底:无 convId 时清所有生成态(卡死恢复场景,精确性不重要) + convStates.clear() + } } diff --git a/src/composables/ai/useAiConversations.ts b/src/composables/ai/useAiConversations.ts index 912355b..a9f1795 100644 --- a/src/composables/ai/useAiConversations.ts +++ b/src/composables/ai/useAiConversations.ts @@ -10,7 +10,7 @@ import { state } from '@/stores/ai' import { notifyConversationChanged } from './useAiEvents' import { persistUiState } from './useAiPanel' import { setStreaming } from './streamingGuard' -import { nextMsgId, getConvState, clearConvStreamState, startApprovalTimer } from './aiShared' +import { nextMsgId, getConvState, clearConvStreamState, startApprovalTimer, clearAllApprovalTimers } from './aiShared' import { t } from '@/i18n/i18n-helpers' import type { AiConversationDetail, AiMessage, AiToolCallInfo, ConvId } from '@/api/types' @@ -76,9 +76,8 @@ async function newConversation() { // 不清 generatingConvs(保留旧 conv 生成态跟踪,侧栏显双会话生成,AiCompleted/AiError 按 // conversation_id 正确收尾旧 conv)。原 state.generatingConvs.clear() 是 A 路线 F-260616-09 // 单 loop 软隔离遗留,真并发下致后台生成前端丢失跟踪,废弃。 - // queue 清空防旧会话排队消息带进新会话 drain;agentRound 复位 0;searchQuery 清空防新会话 - // 侧栏被旧搜索过滤。此三项均为新 conv 本地视图复位(非跨 conv 跟踪),保留。 - state.queue = [] + // agentRound 复位 0;searchQuery 清空防新会话侧栏被旧搜索过滤。 + // queue 保留旧会话排队消息(它们有 conversationId,会在旧会话 AiCompleted 时按 ID 精准 drain) state.agentRound = 0 state.searchQuery = '' await loadConversations() @@ -90,6 +89,9 @@ let _latestSwitchId = 0 /** 切换到指定会话:加载历史消息(含 tool_calls 回填 + tool_result 映射 + pending 审批恢复) */ export async function switchConversation(id: string) { + // AIC-FIX-17-P0-2:切换会话时清空所有审批计时器(防切走后过期 timer 误拒后台 pending + 错误气泡推错视图)。 + // 新会话的审批计时器由下文 restore pending 路径的 startApprovalTimer 重新建立。 + clearAllApprovalTimers() const mySwitchId = ++_latestSwitchId // 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图 let detail: AiConversationDetail @@ -241,7 +243,7 @@ export async function switchConversation(id: string) { dir: tc.dir, reason: tc.reason, }) - // 重新启动审批计时器(APPROVAL_TIMEOUT_MS=Infinity 已决策,不会超时自动拒绝, + // 重新启动审批计时器(APPROVAL_TIMEOUT_MS 默认 15min,0=不限时跳过; // 启动仅用于计时器注册一致性,保持与 AiApprovalRequired 事件处理对称) startApprovalTimer(tc.id, tc.name, kind) } @@ -258,6 +260,9 @@ export async function switchConversation(id: string) { /** 删除会话;若删的是当前活跃会话则清空消息+移除活跃 id 持久化 */ async function deleteConversation(id: string) { + // AIC-FIX-17-P0-2:删除会话时清空所有审批计时器(防已删会话的过期 timer 到期误拒审批 + + // 错误气泡推错视图)。 + clearAllApprovalTimers() await aiApi.deleteConversation(id) // F-09 per-conv:清该会话的 stream state(streaming/currentText),防 convStreamStates Map 无限增长 // (与 convStates/待审批等 per-conv 资源同款会话级清理语义)。 diff --git a/src/composables/ai/useAiEvents.ts b/src/composables/ai/useAiEvents.ts index fffd058..e14473f 100644 --- a/src/composables/ai/useAiEvents.ts +++ b/src/composables/ai/useAiEvents.ts @@ -642,7 +642,9 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | ' function handleLifecycleEvent(event: AiChatEvent): boolean { switch (event.type) { case 'AiCompleted': { + console.warn('[FE-AICOMPLETED] 收到 conv=', event.conversation_id, 'streaming=', state.streaming) cleanupTerminatedConversation(event.conversation_id || '', 'AiCompleted') + console.warn('[FE-AICOMPLETED] cleanup 后 streaming=', state.streaming) // UX-2025-04 / CR-30-2 / 决策 a1: 流中途失败保文——后端 emit AiCompleted(incomplete=true), // 前端追加系统提示气泡(镜像后端 session.messages 的 system 提示)。 // 注:此系统提示仅前端展示,后端已独立 push 到 session.messages 落库。 @@ -674,17 +676,30 @@ function handleLifecycleEvent(event: AiChatEvent): boolean { } else { state.convTokenTotal = { prompt: event.prompt_tokens, completion: event.completion_tokens, total: event.total_tokens } } + // 每轮 token 写入对应 assistant 消息(最后一条 AI 消息),供 MessageList 逐条显示。 + for (let i = state.messages.length - 1; i >= 0; i--) { + const m = state.messages[i] + if (m.role === 'assistant' && !m.isError) { + m.tokenUsage = { prompt: event.prompt_tokens, completion: event.completion_tokens } + break + } + } } notifyConversationChanged() // 队列续发:当前完成后自动发下一条(经事件总线桥接,避免 import useAiSend 构成循环依赖) - emit('ai-drain-queue', {}) + emit('ai-drain-queue', { conversationId: event.conversation_id }) return true } case 'AiError': { cleanupTerminatedConversation(event.conversation_id || '', 'AiError') clearAllApprovalTimers() // UX-260617-10: 错误中断释放所有审批超时计时器,防回调改 state 触发已卸载/已错流程 - state.queue = [] // B-32:错误收尾清队列,防生成中入队的消息被静默丢失(drainQueue 仅 AiCompleted 触发) + // 只清除出错会话的队列项,不误伤其他会话的排队消息 + if (event.conversation_id) { + state.queue = state.queue.filter(q => q.conversationId !== event.conversation_id) + } else { + state.queue = [] + } // UX-260617-10: 错误收尾清残留待审批项——错误发生时若有工具停在 pending_approval, // 残留可点击审批按钮会让用户误以为还能批(实际后端已终止),残留审批卡误导操作。 state.pendingApprovals = [] @@ -809,21 +824,16 @@ export function handleEvent(event: AiChatEvent) { /** 启动事件监听(幂等 + 并发去重,onMounted 与 sendMessage 首发竞态不会重复注册) */ export async function startListener() { // 已注册 → 直接复用(幂等;sendMessage 每次调用不重复注册) - if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) return + if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) { + return + } // 并发去重:防 onMounted 与 sendMessage 首发竞态重复注册 listener → 文字双倍 if (_startPromise) return _startPromise - // UX-260617-09: 内部任一 await reject 时,onMounted/sendMessage 的 await 在 Vue async - // 生命周期/void 调用点处成为未捕获 rejection(无 console 输出)→ listener 未注册且无任何提示, - // 表现为「发消息永远不回」。这里 catch + console.error 保留可观测性,再 throw 让外层 - // finally 清 _startPromise(并发去重复位),调用方仍可 try/catch 兜底。 _startPromise = (async () => { try { _unlistenAiEvent = await aiApi.onEvent(handleEvent) _unlistenConvChanged = await listen('ai-conversation-changed', () => { void loadConversations() }) - // AE-2025-06: onStreamTimeout(流式整流超时收尾)广播的清理事件 → 清全部审批超时计时器。 - // useAiStream 不 import useAiSend(避循环依赖),经事件总线中转。 _unlistenApprovalClear = await listen('ai-approval-clear-timers', () => { clearAllApprovalTimers() }) - // B-260616-01: L0 握手 — 通知后端前端已就绪,后端据此清除 HMR/刷新导致的残留 generating 状态 emit('ai-client-ready', { lastConvId: state.activeConversationId }) } catch (err) { console.error('[useAiEvents] startListener 注册失败,事件监听未生效:', err) diff --git a/src/composables/ai/useAiSend.ts b/src/composables/ai/useAiSend.ts index 8a2031b..1f62474 100644 --- a/src/composables/ai/useAiSend.ts +++ b/src/composables/ai/useAiSend.ts @@ -24,7 +24,7 @@ import { t } from '@/i18n/i18n-helpers' import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream' import { setStreaming } from './streamingGuard' import { startListener } from './useAiEvents' -import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang } from './aiShared' +import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates } from './aiShared' /// 待发送队列上限(超过抛错提示用户) const QUEUE_LIMIT = 10 @@ -90,7 +90,6 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte content: '', timestamp: Date.now(), }) - setStreaming(true, { convId: state.activeConversationId, reason: 'doSend' }) state.currentText = '' resetStreamWatchdog() // 启动流式看门狗,无数据超时兜底 @@ -112,8 +111,11 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte } catch (e) { // IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除 user 消息与空气泡占位; // 重新抛出由 handleSend 回填输入框,用户可重试 - setStreaming(false, { convId: state.activeConversationId, reason: 'doSend-ipc-fail' }) - clearStreamWatchdog() // 清看门狗,防 130s 后 onStreamTimeout 误推错误气泡 + // BUG-2026-07-17:彻底清理 convStates,避免按钮永久红色 + const convId = state.activeConversationId + setStreaming(false, { convId, reason: 'doSend-ipc-fail' }) + clearStreamWatchdog(convId || undefined) + if (convId) convStates.delete(convId) state.messages = state.messages.filter(m => m.id !== aiMsgId && m.id !== userMsgId) throw e } @@ -149,7 +151,9 @@ async function regenerate() { setStreaming(true, { convId: state.activeConversationId, reason: 'regenerate' }) state.currentText = '' - state.queue = [] // 重生成视同新发,清残留队列 + // 只清当前会话的排队消息,不误伤其他会话 + const regenConvId = state.activeConversationId + state.queue = state.queue.filter(q => q.conversationId !== regenConvId) resetStreamWatchdog() await startListener() @@ -219,7 +223,9 @@ async function editMessage(newMessage: string) { setStreaming(true, { convId: state.activeConversationId, reason: 'editMessage' }) state.currentText = '' - state.queue = [] // 编辑重生成视同新发,清残留队列 + // 只清当前会话的排队消息,不误伤其他会话 + const editConvId = state.activeConversationId + state.queue = state.queue.filter(q => q.conversationId !== editConvId) resetStreamWatchdog() await startListener() @@ -251,15 +257,22 @@ async function editMessage(newMessage: string) { * 回填失败消息到队首并停 drain(不静默丢用户输入,保留剩余队列供手动重试/编辑/取消)。 * 成功路径不受影响——doSend 成功后端会再 emit AiCompleted 续 drain 链路。 */ -export function drainQueue() { +export function drainQueue(convId?: string | null) { if (state.queue.length === 0) return - const next = state.queue.shift()! + // 找队首属于目标 conv 的消息;若穿越(非当前 conv 的项堵在队首)则跳过整条队列 + const targetId = convId ?? state.activeConversationId + const idx = state.queue.findIndex(q => q.conversationId === targetId) + if (idx === -1) return + const next = state.queue.splice(idx, 1)[0]! + const activeConvId = state.activeConversationId void (async () => { try { await sendMessage(next.text, next.skill, false, next.parts) } catch (e) { - setStreaming(false, { convId: state.activeConversationId, reason: 'drainQueue-fail' }) - clearStreamWatchdog() + // BUG-2026-07-17: 失败时彻底收尾,带 convId 精准清理 per-conv watchdog + convStates + setStreaming(false, { convId: activeConvId, reason: 'drainQueue-fail' }) + clearStreamWatchdog(activeConvId || undefined) + if (activeConvId) convStates.delete(activeConvId) const errMsg = e instanceof Error ? e.message : String(e) state.queue.unshift(next) // 回填失败消息到队首,保留剩余队列(不静默丢用户输入) state.messages.push({ @@ -313,12 +326,14 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT })) } setStreaming(true, { convId: state.activeConversationId, reason: 'L1-enqueue' }) - // F-260614-05 Phase 2b: 入队项挂 parts(供 drainQueue 续发时本地 user 消息渲染图) + // F-260614-05 Phase 2b: 入队项挂 parts(供 drainQueue/续发时本地 user 消息渲染图) + // 同时挂 conversationId,供 drainQueue 按会话精准续发(防跨会话串话) state.queue.push({ text: text.trim(), skill: skill || undefined, enqueuedAt: Date.now(), parts: parts && parts.length > 0 ? parts : undefined, + conversationId: state.activeConversationId, }) return } @@ -481,5 +496,7 @@ export function useAiSend() { let _drainUnlisten: (() => void) | null = null export async function initDrainQueueListener(): Promise { if (_drainUnlisten) return - _drainUnlisten = await listen('ai-drain-queue', () => { drainQueue() }) + _drainUnlisten = await listen('ai-drain-queue', (event: any) => { + drainQueue(event?.payload?.conversationId ?? null) + }) } diff --git a/src/composables/ai/useAiStream.ts b/src/composables/ai/useAiStream.ts index 3379a14..862dc42 100644 --- a/src/composables/ai/useAiStream.ts +++ b/src/composables/ai/useAiStream.ts @@ -13,14 +13,19 @@ import { state } from '@/stores/ai' import { t } from '@/i18n/i18n-helpers' -import { nextMsgId, getConvState } from './aiShared' +import { nextMsgId, convStates } from './aiShared' import type { MessageId } from '@/api/types' import { forceResetStreaming } from './streamingGuard' import { emit } from '@tauri-apps/api/event' -/// ≥ 后端 STREAM_IDLE_TIMEOUT(120s, stream_recv.rs STREAM_IDLE_TIMEOUT)+余量; -/// 前端若短于后端,慢首 token 会被前端先误杀 -export const STREAM_TIMEOUT_MS = 130000 +/// 流式整体超时(ms)。 +/// +/// 设计权衡: +/// - 后端 stream_llm.rs 首 chunk 超时 10s + STREAM_IDLE_TIMEOUT 120s,正常请求远不到此 +/// - 后端 execute_with_heartbeat 工具执行补 30s AiHeartbeat,正常工具执行也不会到此 +/// - 但 LLM 重试间期(breaker 3轮)无 heartbeat——LLM 生成(~15s)+工具执行(~5s)循环 +/// 3轮总耗约 60-75s,45s 会误杀。改为 90s:覆盖 breaker 3轮 + margin。 +export const STREAM_TIMEOUT_MS = 90000 // TD-260621-01 per-conv 看门狗:模块级单计时器 → convId → timer Map。 // @@ -49,78 +54,50 @@ let _legacyWatchdog: ReturnType | null = null * (legacy fallback 路径)时全局兜底,行为对齐旧版。 */ export function onStreamTimeout(convId?: string) { - // 根治守卫(TD-260621-01 遗留):看门狗 reset(doSend 走 legacy _legacyWatchdog)/clear - // (AiCompleted 走 per-conv _watchdogTimers Map)路径不对齐,clear 传 convId 时只清 Map 不动 legacy, - // 致正常收尾(AiCompleted 已清 streaming)后 legacy timer 残留,130s 到期触发本函数补 - // "⚠ 工具已执行完成,后续回复中断"误报气泡(实测:用户报"回复完后输出该报错")。 - // 守卫:触发时若该 conv 已不在生成态(已正常收尾),判定为残留 timer,静默清不补错误消息。 - // 真卡死(streaming/generating 仍 true)不受影响,仍走原 forceReset + 报错兜底。 - const stillGenerating = convId - ? (() => { - // 批4 双轨收口:读 convStates(enum 真相源)派生,替代旧 generatingConvs.has。 - // 桥接语义:has(convId)=true 等价于 conv_state∈{generating,stopping,compressed}(非终止三态)。 - const cs = getConvState(convId) - return cs === 'generating' || cs === 'stopping' || cs === 'compressed' - })() - : state.streaming - if (!stillGenerating) { - 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 - } + // BUG-2026-07-17 根治性重构:状态机必须能自我收敛。 + // + // 原问题:streaming=true 写在多个入口(doSend/regenerate/edit/L1入队), + // 但 false 只在 cleanupTerminatedConversation 里。任一漏掉即永久卡住。 + // + // 新逻辑:看门狗是状态机自我收敛的唯一包底,超时即彻底重置: + // - 清 streaming=false + // - 清 convStates 项(按钮 stopBtnState 读它) + // - 清 currentText/queue + // - 清 watchdog timer + // 不管 stillGenerating 检测(即使已经 false 也再清理一次,完备包底)。 + console.warn(`[AI-Watchdog] 超时触发,强制收敛状态${convId ? `(convId=${convId})` : '(全局)'}`) + + // 彻底清理三处状态源,保证状态机收敛到 idle + forceResetStreaming('onStreamTimeout(无数据包底)', convId) + if (convId) { + convStates.delete(convId) + } else { + // AIC-FIX-17-P0-1:legacy 单 timer 只跟踪当前活跃会话,不清所有 conv(防误杀其他并发会话)。 + if (state.activeConversationId) { + convStates.delete(state.activeConversationId) } } - // 卡死兜底走 guard 的 forceResetStreaming(复位 streaming + 清 currentText/queue + warn 日志)。 - // 批4 双轨收口:generatingConvs 已退役,本兜底仅复位 streaming 态;该会话 convStates 项由 - // 后端收尾事件(或下次活跃事件兜底写)收敛,无需此处手动清。 - forceResetStreaming('onStreamTimeout(130s 无数据)', convId) clearStreamWatchdog(convId) - // AE-2025-06: 整流超时收尾 → 广播清全部审批超时计时器。 - // 本模块不 import useAiSend(会构成 useAiSend↔useAiStream 循环依赖,与现有破环原则冲突), - // 复用 ai-tool-slow-toast 同款事件总线模式:emit 由 useAiEvents.startListener listen 后调 - // clearAllApprovalTimers。流式已中断,审批继续挂定时器无意义(用户也会随之停止交互)。 + + // 广播清审批计时器(流式已中断,审批计时器无意义) void emit('ai-approval-clear-timers', {}) - // 一次性遍历同时做两件事(单次 O(n),不再分两遍扫): - // 1) 回滚 running 态工具调用 → rejected:approveToolCall 已不乐观置 running(B-260616-08), - // 此分支兜底后端事件先转 running 再 hang 的卡片,避免永久骨架屏。B-260615-07/B-33 残留。 - // (approve 后无 running 转换而 hang 的卡片由 ToolCard 本地 approving 计时器兜底复位, - // 不在此全局回滚 pending_approval——会误伤其他真正待审批的卡片。) - // 2) 探测任一 completed 卡片以区分错误文案(工具已执行完后续回复中断 vs 纯流式中断)。 - // 反向扫尾部提前退出(同 findToolCall 策略);不复用 useAiEvents.findToolCall:本模块 - // 不依赖 useAiEvents(已下沉 nextMsgId 到 aiShared 破环),且此处需全量回滚而非按 id 定位。 - // 注:messages 遍历仍是单例(currentText 一样),per-conv 超时只切到当前 messages 视图; - // 多会话并发下其他会话的 messages 不在此 state.messages(切走即整体替换),无跨会话误回滚。 + + // 检测是否是 "工具已执行完后续中断"(区别文案) let hasCompletedTool = false 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++) { - const tc = toolCalls[j] - // TD-260621-01 修复(用户实测卡死根因):不再把 running→rejected。 - // path_auth/risk 挂起的 toolCall 停在 running(path_auth 不置 pending_approval), - // 看门狗超时自动拒绝会误杀用户未操作的审批(用户报"显示用户拒绝了此操作,但根本没操作")。 - // 看门狗只做流断兜底(清 streaming/currentText/queue),工具/审批态由各自生命周期 - // (AiToolCallCompleted/AiApprovalResult/ai_authorize_dir)管理。running 骨架屏由 - // ToolCard 本地 approving 计时器兜底,或 path_auth 授权后 AiToolCallCompleted 转 completed。 - if (tc.status === 'completed') { + if (toolCalls[j].status === 'completed') { hasCompletedTool = true + break } } + if (hasCompletedTool) break } + + // 补一条错误气泡(仅在实际还在生成态时补,避免重复补) + // 这里直接补——forceResetStreaming 已重置状态,不会重复触发 const content = hasCompletedTool ? t('ai.streamInterruptedAfterTool') : t('ai.streamInterrupted') diff --git a/src/stores/ai.ts b/src/stores/ai.ts index 4bebf08..c0daf79 100644 --- a/src/stores/ai.ts +++ b/src/stores/ai.ts @@ -101,7 +101,7 @@ const _stateBase: { detached: boolean docked: boolean skills: SkillInfo[] - queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[] }[] + queue: { text: string; skill?: string; enqueuedAt: number; parts?: ContentPart[]; conversationId?: string | null }[] agentRound: number /// 已完成工具调用数(本轮累计,AiToolCallCompleted 时 +1,AiAgentRound 时重置) completedTools: number