修复: DeepSeek 400 全量扫描 + 队列 per-conv 隔离
- openai_compat: 扫描所有 assistant 消息剥离 orphan tool_calls(原仅查末条) - queue 加 conversationId 字段,按会话精准 drain - regenerate/editMessage 只清本会话排队消息 - newConversation 保留旧会话排队消息 - AiError 只清出错会话的队列项
This commit is contained in:
@@ -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)。
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,无独立逻辑,不重复单测。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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` 模块。
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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` 模块。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(非 DoubleEnded),collect 成 Vec 再反向遍历。
|
||||
// 单对话消息量小(百级),collect 开销可忽略。
|
||||
let msgs: Vec<&ChatMessage> = conv.messages.iter().collect();
|
||||
// BUG-260624-03/P0 重构:短 lock 段读 messages + 找旧 tool_call_id + 旧 tool_result content,
|
||||
// drop 锁后再锁外 await DB 查 status(原代码持锁 await audit_repo,违反持锁 await 慢操作禁令)。
|
||||
// 第一步:锁内(async block 包裹,出 block 自动 drop guard)反向扫描,定位旧 tool_call_id 与对应 tool_result content
|
||||
let cached: Option<(String, String)> = (async {
|
||||
let session = session_arc.lock().await;
|
||||
// F-260616-09 B 批2:读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv,
|
||||
// 故 conv_read 必命中;防御性 None 时返 None(无缓存命中,走原审批流程)。
|
||||
let Some(conv) = session.conv_read(conv_id) else { return None };
|
||||
// ContextManager::iter 返回 impl Iterator(非 DoubleEnded),collect 成 Vec 再反向遍历。
|
||||
// 单对话消息量小(百级),collect 开销可忽略。
|
||||
let msgs: Vec<&ChatMessage> = conv.messages.iter().collect();
|
||||
|
||||
// 1) 反向扫描 assistant tool_calls,找最近一条同名同参的 High 工具调用 → 拿到旧 tool_call_id
|
||||
// 反向:循环是「最近一次超时→重试」,命中通常是末尾附近,反向先停省全扫。
|
||||
let mut prev_tool_call_id: Option<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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||
use df_ai::provider::ChatMessage;
|
||||
@@ -62,10 +63,10 @@ pub use restore::restore_pending_approvals;
|
||||
mod finalize;
|
||||
pub(crate) use finalize::audit_finalize;
|
||||
|
||||
// cache(audit/cache.rs):F-260616-05 高危工具去重缓存。
|
||||
// 第三批从本文件抽离,行为零变更。
|
||||
// cache(audit/cache.rs):F-260616-05 高危工具去重缓存 + BUG-2026-07-07/P0-2 只读工具缓存。
|
||||
// 第三批从本文件抽离,行为零变更(P0-2 为新增能力)。
|
||||
mod cache;
|
||||
pub(super) use cache::pending_placeholder_for;
|
||||
pub(super) use cache::{find_cached_readonly_result, pending_placeholder_for};
|
||||
|
||||
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
|
||||
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
|
||||
@@ -136,11 +137,25 @@ async fn execute_with_heartbeat(
|
||||
});
|
||||
// RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。
|
||||
let _guard = HeartbeatGuard { stop, handle: heartbeat };
|
||||
tools.execute(name, args).await
|
||||
// BUG-2026-07-19: tools.execute 无 timeout 时,卡死工具(run_command 长命令/read_file 大文件/同步
|
||||
// 阻塞工具)永久挂起 → process_tool_calls 持 session lock 永久 → guard.reset 等 lock → AiCompleted
|
||||
// 永不发 → 前端"回答完卡住/超时清空"。60s timeout 兜底:超时返错误 tool_result,锁释放,loop 续跑。
|
||||
// 心跳 30s 续命前端 watchdog,60s timeout 覆盖绝大多数工具(run_command 已自带 10s 子超时)。
|
||||
match tokio::time::timeout(Duration::from_secs(60), tools.execute(name, args)).await {
|
||||
Ok(result) => result,
|
||||
Err(_elapsed) => {
|
||||
tracing::error!(
|
||||
conv_id = %conv_id,
|
||||
tool = %name,
|
||||
"[ai] 工具执行超时(60s),返回错误 tool_result(防 process_tool_calls 持 session lock 永久卡死)"
|
||||
);
|
||||
Err(anyhow::anyhow!("工具执行超时(60s),已中止(防死锁)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn process_tool_calls(
|
||||
session: &mut AiSession,
|
||||
session_arc: &Arc<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.messages(audit/mod.rs:882),此处取末条 assistant id 作为本轮工具
|
||||
// 调用所属的溯源 message_id,贯穿所有 audit_tool_call 写入。None 表示无 assistant 消息
|
||||
// (异常路径/老数据无 id),audit 落 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 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。
|
||||
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
|
||||
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
|
||||
//
|
||||
// BUG-260624-03/P0 重构:detect_retry_count(DB 读)/emit/audit_tool_call(DB 写)均在锁外,
|
||||
// 仅 push tool_result + pending_approvals.insert 两处纯写改短 lock 段。
|
||||
for (draft, args, req) in path_auth_pending {
|
||||
pending_count += 1;
|
||||
// L1 补丁:req.dirs 含所有未授权父目录;emit 用首个作主展示目录(前端弹窗主显)。
|
||||
@@ -248,14 +304,23 @@ pub(crate) async fn process_tool_calls(
|
||||
.map(|d| d.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let path_str = req.raw_paths.first().cloned().unwrap_or_default();
|
||||
// 阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
||||
// 锁外:阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
||||
let retry_count = detect_retry_count(&audit_repo, &draft.id).await;
|
||||
if retry_count >= 1 {
|
||||
let skip_msg = format!(
|
||||
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
|
||||
draft.id
|
||||
);
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
||||
// 短 lock 段:push tool_result(纯写)
|
||||
{
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] process_tool_calls:305 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
}
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(skip_msg.clone()),
|
||||
@@ -271,22 +336,31 @@ pub(crate) async fn process_tool_calls(
|
||||
).await;
|
||||
continue;
|
||||
}
|
||||
session.pending_approvals.insert(
|
||||
draft.id.clone(),
|
||||
PendingApproval {
|
||||
tool_call_id: draft.id.clone(),
|
||||
tool_name: draft.name.clone(),
|
||||
arguments: args.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
recovered: false,
|
||||
// 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
|
||||
kind: ApprovalKind::Path(req),
|
||||
retry_count,
|
||||
created_at: Some(std::time::SystemTime::now()),
|
||||
},
|
||||
);
|
||||
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||||
// 短 lock 段:pending_approvals.insert + 占位 tool_result push(两处纯写,无 await)
|
||||
{
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let mut session = session_arc.lock().await;
|
||||
session.pending_approvals.insert(
|
||||
draft.id.clone(),
|
||||
PendingApproval {
|
||||
tool_call_id: draft.id.clone(),
|
||||
tool_name: draft.name.clone(),
|
||||
arguments: args.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
recovered: false,
|
||||
// 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
|
||||
kind: ApprovalKind::Path(req),
|
||||
retry_count,
|
||||
created_at: Some(std::time::SystemTime::now()),
|
||||
},
|
||||
);
|
||||
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] process_tool_calls:325 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
}
|
||||
tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)");
|
||||
let ev = AiChatEvent::AiDirAuthRequired {
|
||||
id: draft.id.clone(),
|
||||
@@ -351,17 +425,17 @@ pub(crate) async fn process_tool_calls(
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
handle_approval_tool(
|
||||
draft, args, risk_level, &auto_exec_mode,
|
||||
session, conv_id, tools_arc, &audit_repo,
|
||||
session_arc, conv_id, tools_arc, &audit_repo,
|
||||
app_handle, db, &mut low_risk, &mut trust_hits,
|
||||
&mut pending_count, current_message_id,
|
||||
).await;
|
||||
}
|
||||
|
||||
// AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,非"锁已释放"——
|
||||
// session 锁仍由调用方 agentic/mod.rs 持有至 process_tool_calls 返回,CR-53 审查纠正原"移锁外"误述),
|
||||
// push tool_result / audit 在 join_all 后串行回填(持锁)。对齐 Low risk 并行模式。
|
||||
// AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,
|
||||
// BUG-260624-03/P0 重构后调用方不再持锁,本段所有 await 完全在锁外),
|
||||
// push tool_result / audit 在 join_all 后串行回填(短 lock push + 锁外 audit)。对齐 Low risk 并行模式。
|
||||
// CR-51 修:原 inline .await execute 串行执行每个工具(阻塞期间锁被持有,run_command 慢命令
|
||||
// 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间(锁持有时长不变,并行化降阻塞)。
|
||||
// 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间。P0 重构后锁外并行,根本消除阻塞。
|
||||
// join_all 保序——结果顺序 = trust_hits 输入顺序 = tc_list 原始 index 顺序,不额外 sort
|
||||
if !trust_hits.is_empty() {
|
||||
let results: Vec<(ToolCallDraft, RiskLevel, Result<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 result(create_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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 调用并发上限(运行时调整,立即生效)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 指向,避免悬空
|
||||
|
||||
@@ -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(°enerated), "", "重复拼接的退化输出应被丢弃");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_summary_keeps_normal_summary_with_lists() {
|
||||
// 正常摘要(含列表项重复词)不应被误判为退化
|
||||
let normal = "## 意图\n- 整理文档\n## 决策\n- 使用方案A\n- 使用方案A\n- 使用方案A\n## 约束\n不超 100 字";
|
||||
assert_eq!(clean_summary(normal), normal, "正常摘要不应被误判");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_degenerated_repetition_detects_5x_repeat() {
|
||||
// 5 次重复阈值边界测试
|
||||
let fragment = "这是一个测试片段abcdef";
|
||||
assert!(is_degenerated_repetition(&fragment.repeat(5)));
|
||||
assert!(is_degenerated_repetition(&fragment.repeat(41)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_degenerated_repetition_ignores_short_text() {
|
||||
// 短文本不触发(即使重复)
|
||||
assert!(!is_degenerated_repetition("短文本短文本短文本短文本"));
|
||||
assert!(!is_degenerated_repetition("ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_prompt_returns_template_per_lang() {
|
||||
// 中文模板(默认)
|
||||
|
||||
@@ -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 条目,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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\
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,16 +287,23 @@ type SkillsGuard = RwLockReadGuard<'static, Option<Vec<SkillInfo>>>;
|
||||
///
|
||||
/// 返 `RwLockReadGuard<Option<Vec<SkillInfo>>>`,调用方解 `*guard` 得 `&Vec<SkillInfo>`。
|
||||
/// 懒初始化走双检锁:先读锁查 Some(快),None 时释放 → 扫盘 → 写锁填回 → 读锁重取。
|
||||
fn skills_lock() -> SkillsGuard {
|
||||
// 快路径:读锁命中
|
||||
///
|
||||
/// P1-260617-3:`scan_skills` 同步递归 `fs::read_dir` + `read_to_string`(plugins/marketplaces
|
||||
/// 多层嵌套,Windows 文件多时同步阻塞 tokio runtime)。本函数改 async,慢路径扫盘包
|
||||
/// `spawn_blocking` 隔离(对齐 commands/project.rs detect_stack 模式)。快路径(读锁命中)仍同步无 fs。
|
||||
async fn skills_lock_async() -> SkillsGuard {
|
||||
// 快路径:读锁命中(无 fs,纯内存)
|
||||
{
|
||||
let g = RwLock::read(&SKILLS).expect("SKILLS poisoned");
|
||||
if g.is_some() {
|
||||
return g;
|
||||
}
|
||||
}
|
||||
// 慢路径:扫盘 + 写锁填回
|
||||
let scanned = scan_skills().skills;
|
||||
// 慢路径:扫盘(spawn_blocking 隔离同步 fs 递归,防阻塞 tokio runtime)
|
||||
let scanned = tokio::task::spawn_blocking(scan_skills)
|
||||
.await
|
||||
.map(|res| res.skills)
|
||||
.unwrap_or_default();
|
||||
{
|
||||
let mut g = RwLock::write(&SKILLS).expect("SKILLS poisoned");
|
||||
// 另一线程可能已填,二次检查(双检锁)
|
||||
@@ -314,8 +321,11 @@ fn skills_lock() -> SkillsGuard {
|
||||
///
|
||||
/// 替代原 `OnceLock::get_or_init` 路径:返 owned `Vec<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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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() 返回 Err:SSE 传输断/解析错等
|
||||
MidStream,
|
||||
@@ -144,282 +142,335 @@ pub(crate) enum StreamResult {
|
||||
/// 前端 watchdog 据此 reset,区分「LLM 在跑」与「真断」(避免空气泡误报中断)
|
||||
/// - `stop_notify.notified()`:用户点停止即时打断,不再等 chunk 到或 120s idle timeout
|
||||
pub(crate) async fn stream_llm(
|
||||
provider: &dyn LlmProvider,
|
||||
provider: Arc<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")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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 错误)。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 仅写入侧调用,比对侧遗漏致误弹窗。
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"build": {
|
||||
"beforeDevCommand": "bun dev",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
|
||||
Reference in New Issue
Block a user