修复: aichat 切换/新建对话缺陷(队列串会话/回切丢消息/审批误终态化/删除复活/切换链路)
This commit is contained in:
@@ -297,19 +297,19 @@ pub async fn ai_conversation_switch(
|
||||
|
||||
// 后端 per_conv 存全量消息(LLM 上下文用),前端只渲染最近 N 条(分页懒加载,治长对话卡顿)。
|
||||
// PAGE_SIZE:首屏渲染条数,超出的历史消息由前端滚顶加载更多(ai_conversation_load_more IPC)。
|
||||
// 默认从 DB 快照构造首屏渲染;目标正在生成时下方会改用内存最新消息(见 already_live 分支)。
|
||||
// 三变量置 mut 供覆盖。
|
||||
const PAGE_SIZE: usize = 50;
|
||||
let total_count = messages.len();
|
||||
let render_messages: Vec<ChatMessage> = if total_count > PAGE_SIZE {
|
||||
messages[total_count - PAGE_SIZE..].to_vec()
|
||||
let page_start = total_count.saturating_sub(PAGE_SIZE);
|
||||
let mut render_messages: Vec<ChatMessage> = if total_count > PAGE_SIZE {
|
||||
messages[page_start..].to_vec()
|
||||
} else {
|
||||
messages.clone()
|
||||
};
|
||||
let messages_json = serde_json::to_string(&render_messages)
|
||||
.map_err(|e| format!("序列化消息失败: {}", e))?;
|
||||
let has_more = total_count > PAGE_SIZE;
|
||||
let earliest_seq = if !all_records.is_empty() {
|
||||
let mut has_more = total_count > PAGE_SIZE;
|
||||
let mut earliest_seq = if !all_records.is_empty() && total_count > 0 {
|
||||
// 返回首屏最早消息的 seq,前端滚顶加载时传此值作游标
|
||||
let page_start = total_count.saturating_sub(PAGE_SIZE);
|
||||
Some(all_records[page_start].seq)
|
||||
} else {
|
||||
None
|
||||
@@ -321,111 +321,146 @@ pub async fn ai_conversation_switch(
|
||||
let need_title_regen = record.title.is_none() || record.title.as_deref() == Some("新对话");
|
||||
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// F-260616-09 B 批4(决策 e 真并发上线):删除 readonly 分支。
|
||||
// 旧实现:active conv 生成中 → 只读切换(不改 session 状态),因单例 messages 会被新 conv 覆盖。
|
||||
// 决策 e:per_conv 已隔离,切走直接改 active_conversation_id + 新 conv per_conv 惰性建/从 DB reload。
|
||||
// **后台 conv(目标正在跑 loop)的 per_conv 已存在则跳过 reload**——防覆盖其内存 messages
|
||||
// (后台 loop 正在写自己的 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息)。
|
||||
// 真并发下切走直接改 active_conversation_id + 新 conv per_conv 惰性建/从 DB reload;
|
||||
// 目标正在跑 loop 时保留其 per_conv 现状(防 DB 旧快照覆盖 loop 正在写的内存新消息)。
|
||||
session.active_conversation_id = Some(conversation_id.clone());
|
||||
// F-260619-03 Phase B: 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为
|
||||
// "当前活跃会话的临时授权",不跨会话继承)。
|
||||
// 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为"当前活跃会话的临时授权",
|
||||
// 不跨会话继承)。
|
||||
drop(session);
|
||||
state.clear_session_allowed_dirs().await;
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 读点(B-Phase2):改读无锁 conv_states.is_active()(Generating/Compressed),判断目标会话是否
|
||||
// 已在生成中(是否需要从 DB reload)。语义等价(同一 ConvState 值)——压缩期间后台 loop
|
||||
// 仍持有 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息,应判为已在生成而跳过 reload。
|
||||
// 读点:改读无锁 conv_states.is_active()(Generating/Compressed),判断目标会话是否已在生成中
|
||||
// (是否需要从 DB reload)。压缩期间后台 loop 仍持有 per_conv.messages,reload 会用 DB 旧快照
|
||||
// 覆盖内存新消息,应判为已在生成而跳过 reload。
|
||||
let already_live = state.conv_states.is_active(&conversation_id);
|
||||
if !already_live {
|
||||
if already_live {
|
||||
// 目标正在生成 → DB 快照落后于内存,首屏渲染改用内存 per_conv 最新消息,
|
||||
// 否则前端切过去看到旧内容 + 本轮首响应气泡丢失。per_conv 无该 conv(异常)保持 DB 回退。
|
||||
if let Some(mem_msgs) = session.conv_read(&conversation_id).map(|c| c.messages.all_messages_clone()) {
|
||||
if !mem_msgs.is_empty() {
|
||||
let mtotal = mem_msgs.len();
|
||||
let mstart = mtotal.saturating_sub(PAGE_SIZE);
|
||||
render_messages = if mtotal > PAGE_SIZE {
|
||||
mem_msgs[mstart..].to_vec()
|
||||
} else {
|
||||
mem_msgs
|
||||
};
|
||||
has_more = mtotal > PAGE_SIZE;
|
||||
// 内存消息 DB seq ≈ 索引(头部历史与 DB 逐条对齐),页首条索引作游标
|
||||
earliest_seq = Some(mstart as i64);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。
|
||||
// 已在生成:保留其 per_conv 现状(后台 loop 持有),messages 由 loop 自行维护。
|
||||
let conv = session.conv(&conversation_id);
|
||||
conv.messages.restore_from_messages(messages);
|
||||
// reload 仅在内存无「未落库新消息」时才覆盖 per_conv.messages:
|
||||
// 判据 = 内存消息数 > 已持久化计数 或 有全量重写标记(len > persisted_msg_count 或
|
||||
// needs_full_rewrite,说明内存比 DB 新/脏)。核心原则:宁可保留内存态(内存通常最新),
|
||||
// 绝不用 DB 旧快照覆盖内存新消息——save 是后台异步落库,用户在落库前切走再切回,
|
||||
// 或 save 超时/写失败、断路器熔断退出不 save 时,DB 都落后于内存,restore 会丢末轮消息。
|
||||
let mem_has_unsaved = {
|
||||
let m = &conv.messages;
|
||||
m.len() > m.persisted_msg_count() || m.needs_full_rewrite()
|
||||
};
|
||||
if !mem_has_unsaved {
|
||||
conv.messages.restore_from_messages(messages);
|
||||
// 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化)
|
||||
// 兼容旧格式:["text1","text2"] 转为 [{text, status: active}]
|
||||
let pinned_goals: Vec<crate::commands::ai::GoalEntry> = {
|
||||
let raw: Option<serde_json::Value> = record.pinned_goals
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok());
|
||||
match raw {
|
||||
Some(serde_json::Value::Array(arr)) => {
|
||||
arr.iter().filter_map(|v| {
|
||||
// 新格式: {"text":"...","status":"active"}
|
||||
if let Some(obj) = v.as_object() {
|
||||
let text = obj.get("text")?.as_str()?.to_string();
|
||||
let status = obj.get("status").and_then(|s| s.as_str())
|
||||
.map(|s| if s == "completed" {
|
||||
crate::commands::ai::GoalStatus::Completed
|
||||
} else {
|
||||
crate::commands::ai::GoalStatus::Active
|
||||
}).unwrap_or(crate::commands::ai::GoalStatus::Active);
|
||||
Some(crate::commands::ai::GoalEntry { text, status })
|
||||
} else {
|
||||
// 旧格式: "text" → 转 active
|
||||
let text = v.as_str()?.to_string();
|
||||
Some(crate::commands::ai::GoalEntry::new(text))
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
};
|
||||
conv.pinned_goals = pinned_goals;
|
||||
}
|
||||
// 会话级临时态复位(无论是否 restore messages,重入即复位)
|
||||
conv.model_override = None;
|
||||
conv.session_trust.clear();
|
||||
conv.agent_language = None;
|
||||
conv.iteration_used = 0;
|
||||
conv.stop_flag.store(false, Ordering::SeqCst);
|
||||
// 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化)
|
||||
// 兼容旧格式:["text1","text2"] 转为 [{text, status: active}]
|
||||
let pinned_goals: Vec<crate::commands::ai::GoalEntry> = {
|
||||
let raw: Option<serde_json::Value> = record.pinned_goals
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok());
|
||||
match raw {
|
||||
Some(serde_json::Value::Array(arr)) => {
|
||||
arr.iter().filter_map(|v| {
|
||||
// 新格式: {"text":"...","status":"active"}
|
||||
if let Some(obj) = v.as_object() {
|
||||
let text = obj.get("text")?.as_str()?.to_string();
|
||||
let status = obj.get("status").and_then(|s| s.as_str())
|
||||
.map(|s| if s == "completed" {
|
||||
crate::commands::ai::GoalStatus::Completed
|
||||
} else {
|
||||
crate::commands::ai::GoalStatus::Active
|
||||
}).unwrap_or(crate::commands::ai::GoalStatus::Active);
|
||||
Some(crate::commands::ai::GoalEntry { text, status })
|
||||
} else {
|
||||
// 旧格式: "text" → 转 active
|
||||
let text = v.as_str()?.to_string();
|
||||
Some(crate::commands::ai::GoalEntry::new(text))
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
};
|
||||
conv.pinned_goals = pinned_goals;
|
||||
}
|
||||
// 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空,
|
||||
// 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的)
|
||||
// → ai_pending_tool_calls 查询 → ai_approve 落库)。
|
||||
// SW-260618-02(7-ipc-approval 配套 stop/clear/force_send 终态化):切走目标 conv 前,
|
||||
// 先把其 pending 审批占位 tool_result 终态化为"会话已切换",防占位残留下次发送喂给 LLM。
|
||||
// 须在 retain 前(遍历即将被丢弃的条目)——chat.rs:44 硬契约,顺序不能反。
|
||||
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已切换");
|
||||
// 阶段3a 单真相源合并:单表 retain(kind 不区分,清本 conv 保留其他)。
|
||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
|
||||
|
||||
// 从 DB 恢复本对话的挂起审批快照(重启/切回时,之前 save_conversation 持久化的 pending_approvals)
|
||||
// 反序列化后插入 session.pending_approvals,使 ai_pending_tool_calls 可查回、审批卡片恢复。
|
||||
//
|
||||
// 陈旧过滤根因修复(2026-08,治"审批待办关不了"):
|
||||
// 对话 pending_approvals JSON 列与 ai_tool_executions 审计表是**两份独立持久化**——
|
||||
// 审批处理时(ai_approve/ai_authorize_dir)若 remove 返 None(内存与 JSON 列不同步 / 恢复竞态),
|
||||
// 走 None 分支不 save → JSON 列不被修剪 → 残留陈旧条目(completed/rejected/failed 的)。
|
||||
// 切回此对话时本段会反复 restore 这些已落定条目,前端 ApprovalPopup/ToolCard 永久显挂起卡,
|
||||
// 用户拒绝又走 None 分支死循环。修复:恢复前用审计表核对,仅恢复审计 status='pending' 的条目,
|
||||
// 跳过已落定的陈旧条目(并即时修剪 JSON 列,防下次再恢复陈旧)。
|
||||
// 首屏渲染序列化延后到 already_live 覆盖之后(内存 override 已生效)。
|
||||
let messages_json = serde_json::to_string(&render_messages)
|
||||
.map_err(|e| format!("序列化消息失败: {}", e))?;
|
||||
// pending 终态化/恢复链路仅对「目标未在生成」执行:生成中时内存 pending_approvals 是
|
||||
// loop 持有的真实挂起,终态化 + 清空 + 从 DB 恢复会误伤 live pending 导致新审批丢失。
|
||||
// 生成中时以内存为准,不动 pending。
|
||||
let restored_pending: std::collections::HashMap<String, crate::commands::ai::PendingApproval> =
|
||||
if let Some(pending_json) = &record.pending_approvals {
|
||||
if pending_json != "{}" && !pending_json.is_empty() {
|
||||
match serde_json::from_str::<
|
||||
std::collections::HashMap<String, crate::commands::ai::PendingApproval>
|
||||
>(pending_json) {
|
||||
Ok(m) => m,
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
"切换对话 {} 反序列化 pending_approvals 失败,原始内容前 200 字: {:?}",
|
||||
conversation_id, &pending_json.chars().take(200).collect::<String>()
|
||||
);
|
||||
std::collections::HashMap::new()
|
||||
if already_live {
|
||||
std::collections::HashMap::new()
|
||||
} else {
|
||||
// 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空,
|
||||
// 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的)
|
||||
// → ai_pending_tool_calls 查询 → ai_approve 落库)。
|
||||
// 切走目标 conv 前,先把其 pending 审批占位 tool_result 终态化为"会话已切换",
|
||||
// 防占位残留下次发送喂给 LLM。须在 retain 前(遍历即将被丢弃的条目)。
|
||||
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已切换");
|
||||
// 阶段3a 单真相源合并:单表 retain(kind 不区分,清本 conv 保留其他)。
|
||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
|
||||
|
||||
// 从 DB 恢复本对话的挂起审批快照(重启/切回时,之前 save_conversation 持久化的 pending_approvals)
|
||||
// 反序列化后插入 session.pending_approvals,使 ai_pending_tool_calls 可查回、审批卡片恢复。
|
||||
//
|
||||
// 陈旧过滤根因修复(2026-08,治"审批待办关不了"):
|
||||
// 对话 pending_approvals JSON 列与 ai_tool_executions 审计表是**两份独立持久化**——
|
||||
// 审批处理时(ai_approve/ai_authorize_dir)若 remove 返 None(内存与 JSON 列不同步 / 恢复竞态),
|
||||
// 走 None 分支不 save → JSON 列不被修剪 → 残留陈旧条目(completed/rejected/failed 的)。
|
||||
// 切回此对话时本段会反复 restore 这些已落定条目,前端 ApprovalPopup/ToolCard 永久显挂起卡,
|
||||
// 用户拒绝又走 None 分支死循环。修复:恢复前用审计表核对,仅恢复审计 status='pending' 的条目,
|
||||
// 跳过已落定的陈旧条目(并即时修剪 JSON 列,防下次再恢复陈旧)。
|
||||
let restored = if let Some(pending_json) = &record.pending_approvals {
|
||||
if pending_json != "{}" && !pending_json.is_empty() {
|
||||
match serde_json::from_str::<
|
||||
std::collections::HashMap<String, crate::commands::ai::PendingApproval>
|
||||
>(pending_json) {
|
||||
Ok(m) => m,
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
"切换对话 {} 反序列化 pending_approvals 失败,原始内容前 200 字: {:?}",
|
||||
conversation_id, &pending_json.chars().take(200).collect::<String>()
|
||||
);
|
||||
std::collections::HashMap::new()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
std::collections::HashMap::new()
|
||||
}
|
||||
} else {
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
// 仅恢复本 conv 的条目(已按 conv_id 过滤写入,DB 快照天然是本 conv 的)
|
||||
session.pending_approvals.extend(restored.clone());
|
||||
if !restored.is_empty() {
|
||||
tracing::info!(
|
||||
"切换对话 {} 恢复 {} 条挂起审批(来自 DB pending_approvals 列,待审计核对)",
|
||||
conversation_id, restored.len()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
std::collections::HashMap::new()
|
||||
restored
|
||||
};
|
||||
// 仅恢复本 conv 的条目(已按 conv_id 过滤写入,DB 快照天然是本 conv 的)
|
||||
session.pending_approvals.extend(restored_pending.clone());
|
||||
if !restored_pending.is_empty() {
|
||||
tracing::info!(
|
||||
"切换对话 {} 恢复 {} 条挂起审批(来自 DB pending_approvals 列,待审计核对)",
|
||||
conversation_id, restored_pending.len()
|
||||
);
|
||||
}
|
||||
// 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB)
|
||||
drop(session);
|
||||
|
||||
@@ -433,8 +468,8 @@ pub async fn ai_conversation_switch(
|
||||
// 审计已落定(completed/rejected/failed/interrupted/skipped_retry)→ 视为陈旧,从内存移除,
|
||||
// 并累积"存活"集合用于回写修剪后的 JSON 列(根治:下次切回不再恢复陈旧)。
|
||||
// 审计查询失败(DB 故障)保守信任内存已 extend 的条目(不因 DB 故障误删用户真挂起审批)。
|
||||
// 仅当确实有恢复条目时才跑核对(空 HashMap 跳过,无开销)。
|
||||
if !restored_pending.is_empty() {
|
||||
// 仅当确实有恢复条目时才跑核对(空 HashMap 跳过,无开销);already_live 时 restored 恒空(跳过)。
|
||||
if !already_live && !restored_pending.is_empty() {
|
||||
let mut stale_ids: Vec<String> = Vec::new();
|
||||
for tc_id in restored_pending.keys() {
|
||||
match state.ai_tool_executions.find_by_tool_call_id(tc_id).await {
|
||||
@@ -568,35 +603,37 @@ pub async fn ai_conversation_delete(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<(), String> {
|
||||
// G1.3:单事务删 ai_messages 子行 + ai_conversations 主行(孤儿根治)。
|
||||
// 顺序:先删数据再摘内存(下方 per_conv.remove)——防后台在途 save 在删主行后复活孤儿消息。
|
||||
// 记录已删除 conv_id,必须先于 DB 删除写入:save_conversation 入口检测到即跳过,
|
||||
// 封死「loop 在途 save 复活」窗口(不惰性重建 per_conv、不 Ok(None) INSERT 空壳)。
|
||||
// 删除语义:删了就删了,后续 save 不应复活。conv_id 是 ULID 不重用,标记长期保留。
|
||||
{
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.deleted_convs.insert(conversation_id.clone());
|
||||
}
|
||||
|
||||
// 单事务删 ai_messages 子行 + ai_conversations 主行(防孤儿消息)。
|
||||
// 顺序:先删数据再摘内存(下方 per_conv.remove)。
|
||||
state.ai_conversations.delete_with_messages(&conversation_id).await.map_err(err_str)?;
|
||||
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 删除任意对话(含非活跃)都应清理其积压审批:挂起审批是会话级 HashMap,
|
||||
// 非活跃对话的恢复审批(recovered,conversation_id 指向被删对话)若不 retain 清理,
|
||||
// 会永久残留死审批条目。对齐 ai_conversation_switch 的 retain 口径(仅清目标对话,保留其他)。
|
||||
// SW-260618-02(7-ipc-approval 配套 stop/clear/force_send 终态化):删除目标 conv 前,
|
||||
// 先把其 pending 审批占位 tool_result 终态化为"会话已删除",防占位残留下次发送喂给 LLM。
|
||||
// 须在 retain 前(遍历即将被丢弃的条目)——chat.rs:44 硬契约,顺序不能反。
|
||||
// 删除目标 conv 前,先把其 pending 审批占位 tool_result 终态化为"会话已删除",
|
||||
// 防占位残留下次发送喂给 LLM。须在 retain 前(遍历即将被丢弃的条目)。
|
||||
super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已删除");
|
||||
// 阶段3a 单真相源合并:单表 retain(kind 不区分)。
|
||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
|
||||
// F-260616-09 B 批4:删除 conv 时移除其 per_conv 条目(设计 §4.1 conv 存在性判据依赖此,
|
||||
// 旧 loop 检测 conv 不存在即退出)。per_conv 唯一真相源,删顶层 messages.clear 双写。
|
||||
// 删除 conv 时移除其 per_conv 条目(loop 检测 conv 不存在即退出)。
|
||||
session.per_conv.remove(&conversation_id);
|
||||
// B-Phase4:conv 删除同步清理 ConvStateStore 条目,防已删 conv 残留
|
||||
// Generating 态致 id 复用(同一 conv_id 重新创建)脏状态。
|
||||
// conv 删除同步清理生成状态条目,防已删 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;
|
||||
}
|
||||
// F-260616-09 B 批5:同时清理 LlmConcurrency 的 per_conv Semaphore 条目(防 HashMap 无限增长)。
|
||||
// conv 已删=LlmConcurrency 该条目不再被 acquire(无 conv 则无 loop/标题/提炼/压缩针对它)。
|
||||
// 已持 permit 不受影响(permit 绑旧 Arc,随 Drop 释放),仅阻止新条目累积。
|
||||
// 时机:conv 删除即清理(比"loop 结束 + 无 pending"更确定——conv 删了必无 pending,
|
||||
// 上述 retain 已清)。loop 正常收敛/达 MAX/stop 但 conv 未删时不清理(下次发消息复用,限流计数连续)。
|
||||
// 同时清理该 conv 的限流 Semaphore 条目(防 HashMap 无限增长)。
|
||||
// 已持 permit 不受影响(绑旧 Arc,随 Drop 释放),仅阻止新条目累积。
|
||||
drop(session); // 释放 AiSession 锁再取 LlmConcurrency 锁(避免潜在锁序问题)
|
||||
state.llm_concurrency.release_conv(&conversation_id).await;
|
||||
// F-260619-03 Phase B: 删除的是当前活跃会话 → 清空其临时授权目录(session 字段语义为
|
||||
|
||||
@@ -223,10 +223,8 @@ async fn save_conversation_inner(
|
||||
// 累积致 token 暴增。仅影响持久化视图,不污染内存真相源(ContextManager)——build_for_request
|
||||
// 仍读全量 messages。
|
||||
//
|
||||
// F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源:本函数入参,与 save 落库的 conv 一致)。
|
||||
// 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(幂等不污染)。
|
||||
// per_conv.messages 是唯一真相源(conv_id 来源:本函数入参,与 save 落库的 conv 一致)。
|
||||
// conv() 惰性建:save 路径 conv 必然已建;极端未建(如启动恢复无 live conv)建空态,save 空消息(幂等)。
|
||||
// BUG-2026-07-19: lock 段只 clone 必要读(messages + 元数据),truncate 移出 lock。
|
||||
// 原 clone+truncate 全在 lock 内,对话历史长(多轮+大工具结果)时 clone+truncate 持锁可达秒级,
|
||||
// 致 guard.reset/process_tool_calls 等 session lock 竞争超时(aichat 卡死连环:工具卡片不呈现+
|
||||
@@ -238,6 +236,16 @@ async fn save_conversation_inner(
|
||||
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。
|
||||
let (mut msgs, provider_id, created_at, pinned_goals, persisted_count, needs_full_rewrite) = {
|
||||
let mut session = session_arc.lock().await;
|
||||
// 已删除对话直接跳过写入(loop 在「push 后、save 前」被删除的幽灵复活根治):
|
||||
// 此处不惰性重建 per_conv、不触发 INSERT 空壳 + insert_batch 复活。
|
||||
// 删除语义:删了就删了,后续 save 不应复活。return 前 guard 自动 drop(MutexGuard),无持锁泄漏。
|
||||
if session.deleted_convs.contains(conv_id) {
|
||||
tracing::debug!(
|
||||
"save_conversation 跳过已删除对话(loop 在途 save 不复活): conv_id={}",
|
||||
conv_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
let __wait = __lock_start.elapsed();
|
||||
let provider_id = session.active_provider_id.clone();
|
||||
let created_at = session.active_conv_created_at.clone();
|
||||
@@ -386,6 +394,19 @@ async fn save_conversation_inner(
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// 兜底二次校验(封在途 save 走到 Ok(None) 的复活窗口):save 起始时可能尚未
|
||||
// 记录删除标记,但 get_by_id 恰在 DB 删除之后返回 Ok(None)——INSERT 前再确认一次。
|
||||
let deleted_now = {
|
||||
let session = session_arc.lock().await;
|
||||
session.deleted_convs.contains(conv_id)
|
||||
};
|
||||
if deleted_now {
|
||||
tracing::debug!(
|
||||
"save_conversation 落库前检测到对话已删除,跳过 INSERT(不复活): conv_id={}",
|
||||
conv_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 懒创建首次落库(此为空对话不落库的落库点:走到这里 messages 必非空)
|
||||
// messages JSON 列首次落库也写(兼容未跑迁移的老库 fallback 读路径),
|
||||
// 同时写 ai_messages(新读路径真相源)。
|
||||
|
||||
@@ -586,10 +586,8 @@ fn normalize_dir_key(dir: &str) -> String {
|
||||
|
||||
/// AI 会话内状态(Mutex 保护)
|
||||
///
|
||||
/// F-260616-09 B 批4(决策 e 真并发上线):会话级状态全部迁入 [`per_conv`](Self::per_conv)
|
||||
/// HashMap(按 conversation_id 切分),顶层单例字段(messages/generating/stop_flag/notify/
|
||||
/// iteration_used/agent_language/model_override/session_trust)已退役删除。批2 双写桥接代码
|
||||
/// (IPC 写路径的顶层双写行 + 各文件 fallback 读)在批4 全部清理完毕,per_conv 是唯一真相源。
|
||||
/// 会话级状态全部迁入 [`per_conv`](Self::per_conv) HashMap(按 conversation_id 切分),
|
||||
/// 顶层单例会话级字段已全部退役删除,per_conv 是唯一真相源。
|
||||
pub struct AiSession {
|
||||
/// 当前提供商 ID
|
||||
pub active_provider_id: Option<String>,
|
||||
@@ -608,13 +606,17 @@ pub struct AiSession {
|
||||
/// 兜底/回退:本合并是结构性的,回退走 git revert(plan 风险表「分 3a/3b 各 revert」)。
|
||||
/// 兼容靠 kind 字段默认 Risk(老构造点不传 kind 即默认 Risk,语义不变)。
|
||||
pub pending_approvals: HashMap<String, PendingApproval>,
|
||||
/// F-260616-09 B 阶段 多会话并发架构 — 会话级状态按 conversation_id 切分。
|
||||
///
|
||||
/// **批4 状态(per_conv 唯一真相源)**:批2-批4 已迁移所有调用方(agentic/mod.rs loop +
|
||||
/// GeneratingGuard + try_continue + commands.rs IPC 写路径 + audit.rs process_tool_calls +
|
||||
/// conversation.rs save + title.rs + knowledge_inject.rs + lib.rs L0)读写
|
||||
/// `session.conv(conv_id).*` / `session.conv_read(conv_id)`。顶层单例会话级字段已全部删除。
|
||||
/// 多会话并发架构 — 会话级状态按 conversation_id 切分,全部调用方经
|
||||
/// `session.conv(conv_id).*` / `session.conv_read(conv_id)` 读写,per_conv 是唯一真相源。
|
||||
pub per_conv: HashMap<String, PerConvState>,
|
||||
/// 已删除对话 ID 集合(delete 在 loop 运行中幽灵复活根治)。
|
||||
///
|
||||
/// `ai_conversation_delete` 删除时记录被删 conv_id;`save_conversation` 入口检测到已删除则
|
||||
/// 跳过写入,防 loop 在「push 后、save 前」删除发生 → save 用 [`conv()`](Self::conv) 惰性重建
|
||||
/// 空 per_conv + `Ok(None)` 分支重新 INSERT 空壳复活 + insert_batch 写回消息。
|
||||
/// 删除语义:删了就删了,loop 后续 save 不应复活。conv_id 是 ULID 不重用,条目长期保留
|
||||
/// (单条 ~40B,删除量级下可忽略)。
|
||||
pub deleted_convs: HashSet<String>,
|
||||
/// T2 工具命名空间存储(运行时缓存,大工具结果不进主队列)
|
||||
pub namespace_store: df_ai::namespace_store::NamespaceStore,
|
||||
}
|
||||
@@ -627,6 +629,7 @@ impl AiSession {
|
||||
active_conv_created_at: None,
|
||||
pending_approvals: HashMap::new(),
|
||||
per_conv: HashMap::new(),
|
||||
deleted_convs: HashSet::new(),
|
||||
namespace_store: df_ai::namespace_store::NamespaceStore::default(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user