修复: 模型停用无效根因 + 上下文归位/压缩守卫
- 模型停用无效根因: 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:
@@ -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 必须复位,否则压缩永久停摆"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user