修复: DeepSeek 400 全量扫描 + 队列 per-conv 隔离
- openai_compat: 扫描所有 assistant 消息剥离 orphan tool_calls(原仅查末条) - queue 加 conversationId 字段,按会话精准 drain - regenerate/editMessage 只清本会话排队消息 - newConversation 保留旧会话排队消息 - AiError 只清出错会话的队列项
This commit is contained in:
@@ -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 条目,
|
||||
|
||||
Reference in New Issue
Block a user