修复: DeepSeek 400 全量扫描 + 队列 per-conv 隔离

- openai_compat: 扫描所有 assistant 消息剥离 orphan tool_calls(原仅查末条)
- queue 加 conversationId 字段,按会话精准 drain
- regenerate/editMessage 只清本会话排队消息
- newConversation 保留旧会话排队消息
- AiError 只清出错会话的队列项
This commit is contained in:
2026-07-20 00:18:30 +08:00
parent 42efb31bbf
commit e9e3578d26
59 changed files with 2875 additions and 1330 deletions

44
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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-streamWindows 兼容)
- 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 修复
## 七、开发约定

View File

@@ -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]

View File

@@ -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()
);
}

View File

@@ -10,7 +10,6 @@
//! 本模块仅保留 Provider struct + implHTTP 调用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_tokensmessage_delta 给累计 output_tokens非增量message_stop 带出。
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)
let mut usage_accum: Option<TokenUsage> = 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<Vec<String>, String>| {
let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = 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))
}

View File

@@ -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;

View File

@@ -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 后,末段正常 chunkfinish_reason及额外 usage-only chunkchoices=[])都带 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<TokenUsage> = 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<Vec<String>, String>| {
let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = 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))
}

View File

@@ -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<String>(每个元素是一个事件 data 字段拼接内容)
use futures::Stream;
use std::pin::Pin;
use std::task::{Context, Poll};
/// SSE 事件流的 data 字段内容
pub type SseEvent = String;
/// 原生 SSE 解析器流:包装 bytes_stream,产出 Vec<SseEvent>(一次 poll 可能产出多个事件)
pub struct SseStream<S> {
inner: S,
buffer: Vec<u8>,
}
impl<S> SseStream<S>
where
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
{
pub fn new(inner: S) -> Self {
Self {
inner,
buffer: Vec::with_capacity(8192),
}
}
/// 从 buffer 解析完整的 SSE 事件(以 \n\n 分隔),返回事件列表
fn parse_events(&mut self) -> Vec<SseEvent> {
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<u8> = 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<S> Stream for SseStream<S>
where
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
{
type Item = Result<Vec<SseEvent>, String>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
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,
}
}
}
}

View File

@@ -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 已初始化")

View File

@@ -101,6 +101,18 @@ pub fn get_provider_secret(id: &str) -> Option<String> {
}
}
/// [`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<String> {
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<usize> {
let providers = repo.list_all().await?;

View File

@@ -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();

10
dev-server.bat Normal file
View File

@@ -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

View File

@@ -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×。说明这两类是未根治的根因,须单独立项。

View File

@@ -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` 弃首 tickAiHeartbeat 到达时前端 `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-convF-09 迁移)
### P0-2 审批计时器切/删会话不清 + 注释与代码矛盾 ✅核验成立
**位置**`useAiConversations.ts:92-257`switch 无 clearApprovalTimer/ `:260-272`delete 无清)/ `aiShared.ts:118`(默认 15minvs `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-convDEC-07a accessor | — |
| convStatesconv_state 真相源) | ✅ 已 per-convMap | 但 watchdog legacy fallback 路径 `clear()` 全局误杀P0-1 |
| queue | ❌ 全局单例 | 跨会话丢消息P1-1、强发错位P1-4 |
| watchdog | ⚠️ 双轨per-conv Map + legacy 单 timer | legacy 路径不清 by 心跳、全局 clearP0-1 |
| _approvalTimers | ❌ 全局 Map | 切/删会话不清P0-2 |
| modelOverride | ❌ 模块级 ref | 切会话丢失手选P1-3 |
| _lastDelta | ❌ 模块级单值 | 跨会话误丢 deltaP1-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

View File

@@ -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
```

View File

@@ -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 单例化)

View File

@@ -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)。

View File

@@ -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<String, ConvState>(行级锁,不同 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<ConvStateStore>` 共享(app_state.conv_states)。所有方法同步无 await,可在任意
/// async 上下文直接调(不竞争 session lock,不阻塞 tokio runtime)。
///
/// 注:`transition` 用 DashMap entry 原子(get + transition_to + write 一致,无 TOCTOU 窗口)。
pub struct ConvStateStore {
inner: DashMap<String, ConvState>,
}
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<ConvState, InvalidTransition> {
// 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<String> {
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");
}
}

View File

@@ -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<ConvState>` 视图层冗余字段已删除。
///
/// 背景:批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<Mutex<AiSession>>,
/// 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<Mutex<AiSession>>, 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::<crate::state::AppState>().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::<crate::state::AppState>().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::<crate::state::AppState>()
.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::<crate::state::AppState>()
.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::<crate::state::AppState>().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,无独立逻辑,不重复单测。

View File

@@ -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,

View File

@@ -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` 模块。

View File

@@ -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<dyn LlmProvider> = match super::secret::build_provider_for(candidate) {
let provider: Arc<dyn LlmProvider> = 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!(

View File

@@ -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` 模块。

View File

@@ -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<Mutex<AiSession>>`,内部短 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<Mutex<AiSession>>,
conv_id: &str,
) -> Option<TrustKey> {
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<Mutex<AiSession>>`,所有慢操作
/// (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<Mutex<AiSession>>,
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<Mutex<AiSession>>`,所有 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<Mutex<AiSession>>,
conv_id: &str,
_tools_arc: &Arc<AiToolRegistry>,
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;
}

View File

@@ -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<Mutex<AiSession>>,
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非 DoubleEndedcollect 成 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非 DoubleEndedcollect 成 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<String> = 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<String> = None;
for msg in msgs.iter().rev() {
if !matches!(msg.role, MessageRole::Assistant) {
continue;
}
// 旧调用的 args 是流式拼接的 JSON 字符串,解析失败跳过(不误判为命中)
let Ok(old_args) = serde_json::from_str::<serde_json::Value>(&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::<serde_json::Value>(&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<Mutex<AiSession>>,
conv_id: &str,
audit_repo: &AiToolExecutionRepo,
tool_name: &str,
args: &serde_json::Value,
) -> Option<String> {
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<String> = 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::<serde_json::Value>(&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 {

View File

@@ -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(
});
// RAIIexecute 无论 Ok/Err/panicunwind_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<Mutex<AiSession>>,
tool_calls_acc: HashMap<u32, ToolCallDraft>,
tools_arc: &Arc<AiToolRegistry>,
db: &Arc<Database>,
@@ -164,9 +179,33 @@ pub(crate) async fn process_tool_calls(
// push 到 per_conv.messagesaudit/mod.rs:882此处取末条 assistant id 作为本轮工具
// 调用所属的溯源 message_id贯穿所有 audit_tool_call 写入。None 表示无 assistant 消息
// (异常路径/老数据无 idaudit 落 message_id=None展示侧兼容。
let current_message_id: Option<String> = 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<String> = 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 路径 → 挂起 loopinsert 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<String, String>)> =
@@ -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::<crate::state::AppState>().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 resultcreate_idea source 补全需解析 result.id + args.source
let results: Vec<(ToolCallDraft, serde_json::Value, RiskLevel, Result<String, String>)> =
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
}

View File

@@ -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)

View File

@@ -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<String>,
) -> Result<bool, String> {
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<String, String> {
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::<AppState>().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<Vec<PendingToolCallInfo>, 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<String>,
) -> 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::<AppState>().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::<AppState>().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::<AppState>().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::<AppState>().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<String, String> {
{
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<String, String> {
let pinned_goals: Vec<crate::commands::ai::GoalEntry>;
{
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 {

View File

@@ -30,7 +30,7 @@ use crate::state::AppState;
#[tauri::command]
pub async fn ai_list_skills() -> Result<Vec<SkillInfo>, String> {
// 命中进程内缓存skills_cached 已 owned Vec直接返回
Ok(skills_cached())
Ok(skills_cached().await)
}
/// 热重载技能列表核心设计6:进程内重载,不重启生效)。
@@ -40,7 +40,7 @@ pub async fn ai_list_skills() -> Result<Vec<SkillInfo>, String> {
#[tauri::command]
pub async fn ai_reload_skills() -> Result<Vec<SkillInfo>, String> {
invalidate_skills();
Ok(skills_cached())
Ok(skills_cached().await)
}
/// 设置 LLM 调用并发上限(运行时调整,立即生效)

View File

@@ -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;

View File

@@ -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<Vec<AiProviderRecord>, 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 指向,避免悬空

View File

@@ -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<usize> = 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(&degenerated), "", "重复拼接的退化输出应被丢弃");
}
#[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() {
// 中文模板(默认)

View File

@@ -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<Mutex<AiSession>>,
db: &Arc<Database>,
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 条目,

View File

@@ -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();

View File

@@ -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()),

View File

@@ -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\

View File

@@ -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<String> {
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<Vec<MentionSpanDto>
#[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<AppState>`,单元测试难构造。
// 改测 R1 核心判定逻辑:用 AiSession + PerConvState 模拟生成态,
// 复核 conv_read + conv_state.is_active() 口径对齐 ai_is_generating。
// (批3 收口后单一 enum 路径,开关 + generating bool 已退役。)
// check_generating_reject 生产实现经 State<AppState> 读 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");
}
}

View File

@@ -287,16 +287,23 @@ type SkillsGuard = RwLockReadGuard<'static, Option<Vec<SkillInfo>>>;
///
/// 返 `RwLockReadGuard<Option<Vec<SkillInfo>>>`,调用方解 `*guard` 得 `&Vec<SkillInfo>`。
/// 懒初始化走双检锁:先读锁查 SomeNone 时释放 → 扫盘 → 写锁填回 → 读锁重取。
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<SkillInfo>`clone
/// 因 RwLock 不能返 `&'static`。调用方(config.rs:30 / read_skill_content_stripped)已同步适配。
pub(crate) fn skills_cached() -> Vec<SkillInfo> {
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<SkillInfo> {
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<String> {
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<String> {
// 在作用域内取 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))
}

View File

@@ -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<char> = 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() 返回 ErrSSE 传输断/解析错等
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<dyn LlmProvider>,
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<u32, ToolCallDraft> = HashMap::new();
let mut finished_received = false;
let mut stopped = false;
let mut final_usage: Option<df_ai::provider::TokenUsage> = None;
// BUG-260617-12: DeepSeek thinking 模式推理内容累积(多轮需回传)
let mut reasoning_content_acc: Option<String> = 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<df_ai::provider::StreamChunk, String>
>(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::<crate::state::AppState>().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::<crate::state::AppState>().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<u32, ToolCallDraft> = HashMap::new();
let mut finished_received = false;
let mut stopped = false;
let mut final_usage: Option<df_ai::provider::TokenUsage> = None;
// BUG-260617-12: DeepSeek thinking 模式推理内容累积(多轮需回传)
let mut reasoning_content_acc: Option<String> = 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::<crate::state::AppState>().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::<crate::state::AppState>().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")
}
// ============================================================

View File

@@ -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<String> {
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 错误)。

View File

@@ -593,7 +593,7 @@ fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
})},
);
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<Database>)
// 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<String> = 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` 未分组模式(如 `<template|<div`),尝试 (?:...) 包裹后重试
let wrapped = format!("(?:{})", pattern);
let mut fix_builder = regex::RegexBuilder::new(&wrapped);
fix_builder.case_insensitive(case_insensitive);
match fix_builder.build() {
Ok(r) => (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",

View File

@@ -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(),

View File

@@ -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<serde_json::Value>) {
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
let result = tokio::task::spawn_blocking(move || -> (String, Vec<serde_json::Value>) {
// 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 <path>(工作树 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<String> {
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<String> {
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<serde_json::Value> = std::thread::spawn(move || -> Vec<serde_json::Value> {
let out = std::process::Command::new("git")
.args([
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
let commits: Vec<serde_json::Value> = tokio::task::spawn_blocking(move || -> Vec<serde_json::Value> {
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<serde_json::Value> = 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<serde_json::Value>, String, Vec<String>, String, String, String) {
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
let (files, diff, parents, author, date, full_message) = tokio::task::spawn_blocking(move || -> (Vec<serde_json::Value>, String, Vec<String>, 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 <hash>`
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<serde_json::Value> = 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 <hash>`(仅 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,

View File

@@ -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<String> = 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::<AppState>();
let dirty_convs: Vec<String> = 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");

View File

@@ -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<crate::commands::ai::agentic::conv_state::ConvStateStore>,
// ── 知识库 ──
/// 知识库 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<T> = Result<T, tokio::task::JoinError>;
impl AppState {
/// 初始化应用状态:打开(或创建)数据库并执行迁移,构建各 Repo 与节点注册表
pub async fn init(db_path: &Path, data_dir: PathBuf) -> Result<Self> {
@@ -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);
}

View File

@@ -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 仅写入侧调用,比对侧遗漏致误弹窗。

View File

@@ -5,6 +5,7 @@
"build": {
"beforeDevCommand": "bun dev",
"beforeBuildCommand": "bun run build",
"devUrl": "http://localhost:1420",
"frontendDist": "../dist"
},
"app": {

View File

@@ -494,6 +494,11 @@ export interface AiMessage {
* (不强制校验变体字段,未知 kind 忽略,对齐后端「新增变体向后兼容」设计)。
*/
augmentations?: Augmentation[]
/**
* 这一轮对话的 token 用量(仅 assistant 消息,每轮 AiCompleted 时回填)。
* undefined=未记录(历史消息/未启用 token 显示)。
*/
tokenUsage?: { prompt: number; completion: number }
timestamp: number
}

View File

@@ -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<boolean>('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 —— <script setup> 顶层 await 会使本组件变成 async setup,
// 而 App.vue / AiDetached.vue 直接挂载 <AiChat /> 未包 <Suspense>,会导致渲染空白。
// 用 .then() fire-and-forget,既不阻塞初始化流程,又保持组件同步 setup。
import('@tauri-apps/api/webviewWindow').then(({ getCurrentWebviewWindow }) =>
getCurrentWebviewWindow()
.setAlwaysOnTop(props.detached ? savedAlwaysOnTop : false)
.catch(() => {/* 静默:webview 不可用时忽略 */}),
)
async function toggleAlwaysOnTop() {
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const win = getCurrentWebviewWindow()

View File

@@ -730,13 +730,13 @@ defineExpose({
</div>
<!-- token 用量(仅最后一条 AI 消息 + 非流式;ai.ts handleEvent 已按 df-show-token-usage 开关过滤 lastTokenUsage) -->
<!-- token 用量(显示在每条 assistant 消息底部,非流式且有数据时) -->
<div
v-if="isLastAi(item.msg) && !store.state.streaming && store.state.lastTokenUsage"
v-if="item.msg.role === 'assistant' && !store.state.streaming && item.msg.tokenUsage"
class="ai-token-usage"
>
<span class="ai-token-usage-icon">🔣</span>
<span>{{ formatTokens(store.state.lastTokenUsage.prompt) }} in · {{ formatTokens(store.state.lastTokenUsage.completion) }} out</span>
<span>{{ formatTokens(item.msg.tokenUsage.prompt) }} in · {{ formatTokens(item.msg.tokenUsage.completion) }} out</span>
</div>
<!-- 工具调用卡片(渲染/折叠/审批全下沉到 ToolCardList+ToolCard 子组件,MessageList 仅转发审批) -->

View File

@@ -225,7 +225,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { aiApi } from '../../api/ai'
import type { AiMessage, ModelConfig } from '../../api/types'
import type { AiMessage, ModelConfig, GoalEntry } from '../../api/types'
defineProps<{
/** 分离窗口模式(嵌入 vs 分离决定顶部按钮组) */
@@ -326,6 +326,20 @@ watch(() => store.state.activeConversationId, () => {
store.modelOverride.value = enabledModels.value[0]?.model_id || null
})
// 切 provider 后模型 override 同步(BUG-2026-07-07:provider 切换后 header 模型名不刷新):
// - modelOverride 是模块级 ref(useAiSend)不持久化,切 provider 后仍持旧 provider 的 model_id,
// 该 id 不在新 provider 池里 → 下拉显示错位(UI 显示 X,实际后端用路由默认 Y)。
// - 守卫:当前选择对新 provider 模型池无效(空/不在池)时,默认选权重最高模型。
// enabledModels 已按 weight 降序排,model[0] 即权重最高。
// - immediate:组件挂载时也校验(modelOverride 为 null → 默认权重最高,与下拉首项对齐)。
watch(enabledModels, (models) => {
const current = store.modelOverride.value
const valid = current && models.some(m => m.model_id === current)
if ((!valid || !current) && models.length) {
store.modelOverride.value = models[0].model_id
}
}, { immediate: true })
// ── 当前对话标题 ──
const currentConvTitle = computed(() => {
const id = store.state.activeConversationId
@@ -416,7 +430,7 @@ const summaryExpanded = ref(false)
// ── L3 完整上下文 ──
interface ContextInfo {
goals: string[]
goals: GoalEntry[]
summaryCount: number
osInfo: string
projectNames: string

View File

@@ -331,7 +331,9 @@ function statusLabelKey(status: IdeaStatus): string {
// B-260615-25:灵感描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例),
// useRendered 封装 computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热)
const { rendered: renderedDesc } = useRendered(
// BUG-2026-07-08:原漏解构 ensureLoaded + onMounted 未调用,冷启动(marked 未被其他组件预热)
// 时 mdReady 永远 false,renderMd 走 escapeFallback 纯文本降级,Markdown 语法不渲染。
const { rendered: renderedDesc, ensureLoaded: ensureMdLoaded } = useRendered(
() => props.idea.description ?? '',
)
@@ -439,7 +441,10 @@ function historySummary(aiAnalysis: string | null | undefined): HistorySummary |
}
}
onMounted(loadHistory)
onMounted(() => {
ensureMdLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
loadHistory()
})
watch(() => props.idea.id, loadHistory)
</script>

View File

@@ -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()
}
}

View File

@@ -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 默认 15min0=不限时跳过;
// 启动仅用于计时器注册一致性,保持与 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 资源同款会话级清理语义)。

View File

@@ -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)

View File

@@ -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<void> {
if (_drainUnlisten) return
_drainUnlisten = await listen('ai-drain-queue', () => { drainQueue() })
_drainUnlisten = await listen('ai-drain-queue', (event: any) => {
drainQueue(event?.payload?.conversationId ?? null)
})
}

View File

@@ -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<typeof setTimeout> | 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')

View File

@@ -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