修复: 模型停用无效根因 + 上下文归位/压缩守卫

- 模型停用无效根因: get_active_provider 两条路径均不校验 enabled(显式活跃直返/默认只挑 is_default) → 收敛为启用集合解析,无启用 provider 明确报错
- 压缩/标题兜底模型不校验 enabled → 新增 resolve_fallback_model 纯函数(title 复用同一实现,单一源无双轨)
- 上下文多夹层归位修复: gather_tool_responses 聚拢该头全部响应(此前第二个夹层之后的响应仍被判未闭合剥除)
- 压缩后台任务加内容版本哨兵(取首条消息 id,否掉 len:压缩期间 loop 正常 append 会致守卫永不生效) + panic 兜底复位标志
- 902 超时类失败与其它 Fatal 区分停靠文案(点继续可续跑)
- 子代理事件按 conv 改挂(route_scope) + 运行期事件推父会话视图
- 飞书 13a 真根因: run_stream_refresher 死代码(提前 drop)+静默钟被心跳重置 → 帧通道+仅内容事件重置
- 项目详情文件查看: F22 目录不存在误显非 Git 仓库 / F24 模块为仓库子目录时 git 徽标全失效(scope_git_path 按 show-prefix 剥前缀)
- 更新徽章通道错配(Tauri 事件 vs window CustomEvent 同名不同通道,徽章此前永不触发)
- ProjectCard 卡片根 keydown 顶替内嵌按钮(补 e.target 守卫)
- i18n 坏键 ai.aiTool.rejectedHint → aiTool.rejectedHint(双缺会渲染裸 key)
- miniapp 三处: 会话列表刷新漏网/看门狗节流致新一轮无兜底/newConversation 漏清看门狗
This commit is contained in:
lxy
2026-10-10 08:08:48 +08:00
parent 2a3a6cd25b
commit 9c68f98d89
24 changed files with 1023 additions and 3644 deletions
@@ -35,6 +35,7 @@ use std::sync::Arc;
use tauri::AppHandle;
use tokio::sync::Mutex;
use df_ai::context::ContextManager;
use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
use df_ai::provider::ChatMessage;
use df_storage::models::AiProviderRecord;
@@ -59,6 +60,32 @@ const PROTECT_COUNT: usize = 6;
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
const KEYWORD_FALLBACK_ENABLED: bool = true;
/// 会话消息列表的内容版本哨兵(939⑪):首条消息的全局唯一 id(空列表 = None)。
///
/// 压缩**触发时**快照本值,后台 task **提交前**重新读取比对:相等才允许提交。
/// 首条消息 id 由 `new_message_id()` 全局唯一——loop 只 append(不改首条),而 `clear()`
/// 重建后首条必换新 id,故它是「上下文是否被整体替换」的准确且零成本哨兵。
/// 不用 len:loop 后台压缩期间正常 append 会使 len 增长,以 len 相等为守卫会误判版本
/// 不符致后台压缩永不生效。
fn head_id_of(messages: &mut ContextManager) -> Option<String> {
messages.messages_mut().first().and_then(|t| t.message.id.clone())
}
/// 内容版本是否仍匹配(939⑪):快照与当前首条 id 相等才允许提交。
fn version_matches(snapshot: &Option<String>, current: &Option<String>) -> bool {
snapshot == current
}
/// panic 兜底复位(939⑫):清 is_compressing 防其永真致压缩永久停摆。
///
/// conv 已删(per_conv 无条目)跳过——标志随 conv 销毁无意义(对齐各正常出口语义)。
async fn reset_compressing_after_panic(session_arc: &Arc<Mutex<AiSession>>, conv_id: &str) {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
session.conv(conv_id).messages.set_compressing(false);
}
}
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
///
/// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。
@@ -114,7 +141,7 @@ pub(super) async fn maybe_auto_compress(
emit_ai_chat_event(app_handle, AiChatEvent::AiCompressing {
conversation_id: Some(conv_id.to_string()),
});
let (mut active_msgs, lang) = {
let (mut active_msgs, lang, head_id) = {
let mut session = session_arc.lock().await;
let conv = session.conv(&conv_id);
conv.messages.set_compressing(true);
@@ -123,6 +150,9 @@ pub(super) async fn maybe_auto_compress(
// 切片前按当时实际长度钳制,防 index out of bounds panic
// (后续 compress_old_messages 内部自带 min(len) 钳制,口径一致)。
let msgs = conv.messages.messages_mut();
// 939⑪ 内容版本哨兵:快照触发时首条消息 id,后台 task 提交前比对
// (clear() 重建后闭包内提交会落到新上下文,靠此守卫丢弃)。
let head_id = msgs.first().and_then(|t| t.message.id.clone());
let cut = protect_start.min(msgs.len());
let active_msgs: Vec<ChatMessage> = msgs[..cut]
.iter()
@@ -131,7 +161,7 @@ pub(super) async fn maybe_auto_compress(
.collect();
let lang = conv.agent_language.clone()
.unwrap_or_else(|| "zh-CN".to_string());
(active_msgs, lang)
(active_msgs, lang, head_id)
};
// T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。
@@ -203,7 +233,15 @@ pub(super) async fn maybe_auto_compress(
let conv_id = conv_id.to_string();
let provider_config = provider_config.clone();
let llm_concurrency = llm_concurrency.clone();
// 939⑫ panic 兜底句柄:内层 async move 会消费 session_arc/conv_id,panic 分支
// 需另持一份用于复位 is_compressing(防其永真致压缩永久停摆)。
let panic_session = session_arc.clone();
let panic_conv_id = conv_id.clone();
tokio::spawn(async move {
// 939⑫ 整个后台 body 包 catch_unwind(复用 mod.rs::unwind_to_result 薄包装):
// panic 以 Err 返回 → 复位 is_compressing + warn,防压缩永久停摆。依赖
// panic=unwind(tokio 默认);panic=abort 构建下退化为进程终止(尽力兜底)。
let outcome = super::unwind_to_result(async move {
// 后台重建 provider(secret::build_provider_for 三步打包:resolve→ensure→build)。
// 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放
// token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降,
@@ -227,6 +265,7 @@ pub(super) async fn maybe_auto_compress(
&keyword_fallback,
pre_compress_tokens,
"压缩 provider 重建失败",
&head_id,
)
.await;
return;
@@ -256,6 +295,16 @@ pub(super) async fn maybe_auto_compress(
return;
}
let conv = session.conv(&conv_id);
// 939⑪ 内容版本守卫:提交前比对触发时快照,不符(期间 clear() 重建致
// 上下文整体替换)→ 丢弃压缩结果。不复位 is_compressing(clear 已复位;
// 若新会话已启新压缩,此处复位会误伤新压缩信令)。
if !version_matches(&head_id, &head_id_of(&mut conv.messages)) {
tracing::warn!(
conv_id = %conv_id,
"[ai] 压缩完成但上下文版本已变(疑似 clear 重建),丢弃旧摘要"
);
return;
}
let _compressed = conv.messages.compress_old_messages(protect_start);
// NL 摘要尾部追加(而非 insert_at(0) 头部插入):头部插入会位移整条
// 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效
@@ -284,7 +333,11 @@ pub(super) async fn maybe_auto_compress(
// 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
session.conv(&conv_id).messages.set_compressing(false);
let conv = session.conv(&conv_id);
// 939⑪ 版本守卫:上下文已被整体替换 → 不复位(否则误伤新会话信令)。
if version_matches(&head_id, &head_id_of(&mut conv.messages)) {
conv.messages.set_compressing(false);
}
}
}
Err(e) => {
@@ -316,10 +369,21 @@ pub(super) async fn maybe_auto_compress(
&keyword_fallback,
pre_compress_tokens,
"压缩失败",
&head_id,
)
.await;
}
}
}) // 闭 catch_unwind 内 async move
.await;
if let Err(msg) = outcome {
reset_compressing_after_panic(&panic_session, &panic_conv_id).await;
tracing::warn!(
conv_id = %panic_conv_id,
error = %msg,
"[ai] 后台压缩 task panic,已复位 is_compressing(防压缩永久停摆)"
);
}
});
}
}
@@ -341,6 +405,10 @@ pub(super) async fn maybe_auto_compress(
///
/// conv 已删(per_conv 无条目)→ 直接返回(标志随 conv 销毁无需复位;对齐原 return 语义:
/// 防惰性重建污染新 conv)。
///
/// **内容版本守卫(939⑪)**:`head_id` 为触发时快照的首条消息 id;提交前比对不符(期间
/// `clear()` 重建致上下文整体替换)→ 直接返回:既不标 compressed / 不插旧关键词摘要污染
/// 新上下文,也不复位 is_compressing(clear 已复位;复位会误伤新会话可能已启的新压缩信令)。
async fn keyword_fallback_commit(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
@@ -348,6 +416,7 @@ async fn keyword_fallback_commit(
keyword_fallback: &str,
pre_tokens: u32,
log_head: &str,
head_id: &Option<String>,
) {
let inserted = {
let mut session = session_arc.lock().await;
@@ -355,6 +424,15 @@ async fn keyword_fallback_commit(
return;
}
let conv = session.conv(conv_id);
// 939⑪ 内容版本守卫:上下文已被整体替换 → 丢弃兜底提交(标志亦不复位)。
if !version_matches(head_id, &head_id_of(&mut conv.messages)) {
tracing::warn!(
conv_id = %conv_id,
"[ai] {}兜底提交前上下文版本已变(疑似 clear 重建),丢弃",
log_head
);
return;
}
// 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。
conv.messages.set_compressing(false);
if !KEYWORD_FALLBACK_ENABLED {
@@ -382,3 +460,123 @@ async fn keyword_fallback_commit(
);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 建 conv 并灌 n 条 active 消息,置 is_compressing(true)(模拟触发时已置位)。
/// 返回触发时的内容版本哨兵(首条消息 id),供 keyword_fallback_commit 的版本守卫比对。
async fn seed_compressing(arc: &Arc<Mutex<AiSession>>, conv_id: &str, n: usize) -> Option<String> {
let mut session = arc.lock().await;
let conv = session.conv(conv_id);
for i in 0..n {
conv.messages.push(if i % 2 == 0 {
ChatMessage::user(format!("用户消息{i}"))
} else {
ChatMessage::assistant(format!("助手回复{i}"))
});
}
conv.messages.set_compressing(true);
head_id_of(&mut conv.messages)
}
/// 非空关键词:复位 is_compressing 与兜底提交(标 compressed + 尾部插锚点)一并生效。
#[tokio::test]
async fn keyword_fallback_commit_resets_and_inserts() {
let arc = Arc::new(Mutex::new(AiSession::new()));
let head = seed_compressing(&arc, "c1", 4).await;
keyword_fallback_commit(&arc, "c1", 4, "关键词锚点", 0, "压缩失败", &head).await;
let session = arc.lock().await;
let conv = session.conv_read("c1").expect("conv 应存在");
assert!(!conv.messages.is_compressing(), "提交后 is_compressing 应复位 false");
let msgs = conv.messages.all_messages_clone();
assert_eq!(msgs.len(), 5, "压缩后应尾部追加 1 条关键词 system 锚点");
for (i, m) in msgs[..4].iter().enumerate() {
assert!(!m.is_active(), "第 {i} 条原消息应被标 compressed(释放 token)");
}
assert_eq!(msgs[4].content, "关键词锚点", "末条应为关键词锚点正文");
assert!(
matches!(msgs[4].role, df_ai::provider::MessageRole::System),
"锚点应为 system 角色"
);
}
/// 空关键词:复位不能丢(否则 is_compressing 永真 → 压缩永久停摆),但不插锚点。
#[tokio::test]
async fn keyword_fallback_commit_empty_keeps_reset() {
let arc = Arc::new(Mutex::new(AiSession::new()));
let head = seed_compressing(&arc, "c2", 4).await;
keyword_fallback_commit(&arc, "c2", 4, "", 0, "压缩 provider 重建失败", &head).await;
let session = arc.lock().await;
let conv = session.conv_read("c2").expect("conv 应存在");
assert!(!conv.messages.is_compressing(), "关键词为空仍应复位 is_compressing");
assert_eq!(conv.messages.len(), 4, "关键词为空不应插锚点,len 不变");
for m in conv.messages.all_messages_clone() {
assert!(!m.is_active(), "空关键词路径仍应标 compressed 扣 token");
}
assert_eq!(conv.messages.history_tokens(), 0, "无锚点插入,token 应清零");
}
/// conv 已删(per_conv 无条目):早返不惰性重建(防污染新 conv)。
#[tokio::test]
async fn keyword_fallback_commit_missing_conv_noop() {
let arc = Arc::new(Mutex::new(AiSession::new()));
keyword_fallback_commit(&arc, "gone", 4, "锚点", 0, "压缩失败", &None).await;
let session = arc.lock().await;
assert!(
session.per_conv.is_empty(),
"conv 已删不应惰性重建 per_conv 条目"
);
}
}
// 939⑪⑫ 后台压缩守卫契约:纯函数级断言(版本不符→丢弃 / panic→标志复位)。
#[cfg(test)]
mod guard_tests {
use super::{head_id_of, reset_compressing_after_panic, version_matches, AiSession};
use df_ai::context::{ContextConfig, ContextManager};
use std::sync::Arc;
use tokio::sync::Mutex;
// 939⑪:内容版本不符(首条 id 变化 / 列表被清空 / 空→非空)即判定丢弃。
#[test]
fn version_guard_discards_on_head_change() {
let a = Some("msg_a".to_string());
let b = Some("msg_b".to_string());
assert!(version_matches(&a, &a), "同版本必须放行");
assert!(!version_matches(&a, &b), "首条 id 变(clear 重建)必须丢弃");
assert!(!version_matches(&a, &None), "列表被清空必须丢弃");
assert!(!version_matches(&None, &a), "空→非空(新会话)必须丢弃");
}
// 939⑪:空列表的内容版本哨兵为 None。
#[test]
fn head_id_of_empty_list_is_none() {
let mut mgr = ContextManager::new(ContextConfig::default());
assert_eq!(head_id_of(&mut mgr), None);
}
// 939⑫:后台 body panic → is_compressing 被复位,压缩不停摆。
#[tokio::test]
async fn panic_resets_compressing_flag() {
let session = Arc::new(Mutex::new(AiSession::new()));
let conv_id = "conv-panic";
session.lock().await.conv(conv_id).messages.set_compressing(true);
// 模拟后台 body panic,经 catch_unwind 薄包装转受控 Err
let r = super::super::unwind_to_result(async { panic!("boom in compress task") }).await;
assert!(r.is_err(), "panic 应被捕获为 Err");
// panic 分支:复位 is_compressing
reset_compressing_after_panic(&session, conv_id).await;
assert!(
!session.lock().await.conv_read(conv_id).unwrap().messages.is_compressing(),
"panic 后 is_compressing 必须复位,否则压缩永久停摆"
);
}
}