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

- 模型停用无效根因: 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
@@ -348,6 +348,9 @@ function clearAllApprovalTimers(): void {
function clearWatchdog(): void { function clearWatchdog(): void {
if (streamTimer) clearTimeout(streamTimer) if (streamTimer) clearTimeout(streamTimer)
streamTimer = null streamTimer = null
// 复位节流时间戳:resetWatchdog 的 5s 节流假定「已有 timer 在跑」,而此处刚清空 timer。
// 不复位则「停止/报错后 5s 内重发」会因节流跳过 arm → 新一轮无看门狗(中途断则 generating 永卡)。
lastWatchdogReset = 0
} }
/** 已注册事件监听标志(防多次订阅 wsClient) */ /** 已注册事件监听标志(防多次订阅 wsClient) */
@@ -1374,6 +1377,7 @@ function newConversation(): void {
tokenUsage.value = null // 新会话清 token(对齐 switchConversation) tokenUsage.value = null // 新会话清 token(对齐 switchConversation)
activeModel.value = null // 新会话复位模型选择(新会话无 per_conv 模型,走默认路由) activeModel.value = null // 新会话复位模型选择(新会话无 per_conv 模型,走默认路由)
pendingApprovals.value.splice(0, pendingApprovals.value.length); clearAllApprovalTimers() pendingApprovals.value.splice(0, pendingApprovals.value.length); clearAllApprovalTimers()
clearWatchdog() // 与 switchConversation 对齐:清残留看门狗,防新一轮 send 沿用旧 timer(超时点偏移)
} }
/** /**
+5 -2
View File
@@ -7,6 +7,9 @@ import { useAiChat } from '@/composables/useAiChat'
import type { MentionSpan, MiniContentPart } from '@/types/relay' import type { MentionSpan, MiniContentPart } from '@/types/relay'
import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo, ChatMessage, TokenUsage } from '@/types/events' import type { ProjectRecord, TaskRecord, IdeaRecord, AiToolCallInfo, ChatMessage, TokenUsage } from '@/types/events'
/** 调试日志门控(对齐 useAiChat/ws.ts:import.meta.env.DEV,生产零 bridge 成本) */
const DEBUG = import.meta.env.DEV
// 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found, // 组件内联(绕过微信工具 3.15.2 组件解析缓存 bug):独立组件恒报 Component not found,
// inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。 // inline 进页面后 chat/index.json usingComponents 空,工具无需解析 components/,问题消失。
// MdView/MentionInput 源保留(src/components/ 下,工具 bug 修复后可拆回)。 // MdView/MentionInput 源保留(src/components/ 下,工具 bug 修复后可拆回)。
@@ -512,11 +515,11 @@ function onMentionClose(): void {
* 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。 * 保留原有守卫:空文本忽略 / generating 中忽略 / 关闭联想浮层。
*/ */
function handleSend(): void { function handleSend(): void {
console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', generating.value, 'imgs=', pendingImages.value.length) if (DEBUG) console.log('[v2:send] enter text=', JSON.stringify(inputText.value), 'gen=', generating.value, 'imgs=', pendingImages.value.length)
const text = inputText.value.trim() const text = inputText.value.trim()
const hasImage = pendingImages.value.length > 0 const hasImage = pendingImages.value.length > 0
if (!text && !hasImage) { if (!text && !hasImage) {
console.log('[v2:send] 空文本且无图片,忽略') if (DEBUG) console.log('[v2:send] 空文本且无图片,忽略')
return return
} }
if (generating.value) { if (generating.value) {
@@ -12,11 +12,15 @@ const ai = useAiChat()
*/ */
const conversations = ref<Conversation[]>([]) const conversations = ref<Conversation[]>([])
/** /**
* 监听 ai.conversations.length 变化(比 deep watch 更可靠)。 * 监听 ai.conversations 整体替换(比 deep watch 更可靠,也比只盯 length 完整)。
* 用 `[...ai.conversations]` 创建普通数组赋值给 ref,确保每次变化都触发模板更新。 * 用 `[...ai.conversations]` 创建普通数组赋值给 ref,确保每次变化都触发模板更新。
*
* 为什么监听 ref 而非 length:device 回推 AiConversationList 是「整体替换」且条数常不变
* (改名后条数不变),只盯 length 时 watch 回调不触发 → 本地副本滞留旧标题。
* 监听 ref 则在赋值(含同长度替换)时即触发刷新。
*/ */
watch(() => ai.conversations.value.length, () => { watch(ai.conversations, (list) => {
conversations.value = [...ai.conversations.value] conversations.value = [...(list || [])]
}, { immediate: true }) }, { immediate: true })
/** 点击会话项切换 */ /** 点击会话项切换 */
+2 -2
View File
@@ -580,7 +580,7 @@ impl ContextManager {
/// 摘要/警示仍紧邻本轮内容对 LLM 可见。返回 `true` 表示走了避让插入(会置 /// 摘要/警示仍紧邻本轮内容对 LLM 可见。返回 `true` 表示走了避让插入(会置
/// `needs_full_rewrite`,需 DB 全量重写),供调用方记日志。 /// `needs_full_rewrite`,需 DB 全量重写),供调用方记日志。
pub fn push_system_anchor(&mut self, message: ChatMessage) -> bool { pub fn push_system_anchor(&mut self, message: ChatMessage) -> bool {
if self.ends_with_dangling_tool_call_head() { if self.tail_is_dangling_head() {
let idx = self.messages.len() - 1; let idx = self.messages.len() - 1;
self.insert_at(idx, message); self.insert_at(idx, message);
tracing::debug!( tracing::debug!(
@@ -596,7 +596,7 @@ impl ContextManager {
/// 末条是否为「悬空 tool_call 头」(assistant 带 tool_calls 且其 tool 响应尚未回填)。 /// 末条是否为「悬空 tool_call 头」(assistant 带 tool_calls 且其 tool 响应尚未回填)。
/// 供 [`Self::push_system_anchor`] 判定避让位置:末条即头,说明头已入而结果未回。 /// 供 [`Self::push_system_anchor`] 判定避让位置:末条即头,说明头已入而结果未回。
fn ends_with_dangling_tool_call_head(&self) -> bool { fn tail_is_dangling_head(&self) -> bool {
self.messages self.messages
.last() .last()
.is_some_and(|m| m.group == MessageGroup::ToolCallHead) .is_some_and(|m| m.group == MessageGroup::ToolCallHead)
+99 -15
View File
@@ -45,8 +45,9 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
// step 0.5(相邻性归位,909②):把楔入 assistant(tool_calls) 头与其 tool 响应之间的异类消息 // step 0.5(相邻性归位,909②):把楔入 assistant(tool_calls) 头与其 tool 响应之间的异类消息
// (后台压缩摘要 / 用户插话 / 警示 system)整体移到该组响应**之后**——只重排不删内容。 // (后台压缩摘要 / 用户插话 / 警示 system)整体移到该组响应**之后**——只重排不删内容。
// 归位后三元组恢复相邻,step1 的邻接判定即可正确识别为已闭合,不再走「未邻接即剥除」而**保住 // 归位后三元组恢复相邻,step1 的邻接判定即可正确识别为已闭合,不再走「未邻接即剥除」而**保住
// 工具轮**(避免丢结果致模型重跑/误判)。真正无法归位者(头无任何响应)仍由 step2/3 剥除兜底。 // 工具轮**(避免丢结果致模型重跑/误判)。对同一头的多段夹层同样生效(全部响应聚拢到头后紧邻)。
let messages = reorder_interlopers_after_tool_run(messages); // 真正无法归位者(头无任何响应)仍由 step2/3 剥除兜底。
let messages = gather_tool_responses(messages);
// step 1:相邻性判定的依据——每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。 // step 1:相邻性判定的依据——每个 assistant 头「紧后 tool 连续段」覆盖的 id 集合。
// 口径与 openai_compat::sanitize_openai_triplets 一致(2026-10-09 收紧): // 口径与 openai_compat::sanitize_openai_triplets 一致(2026-10-09 收紧):
@@ -203,17 +204,25 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
ensure_sequence_legal(after_reverse_orphan) ensure_sequence_legal(after_reverse_orphan)
} }
/// 相邻性归位(909②,view-only):assistant 头与其 tool 响应之间夹有异类消息时,把夹层整体移到 /// 相邻性归位(909②,view-only):把 assistant 头的**全部** tool 响应聚拢到头后紧邻,夹层整体后移。
/// 该头「紧后连续 tool 段」之后。
/// ///
/// 根因:协议不变式要求「tool 响应必须紧随其 assistant(tool_calls)」。后台压缩摘要在 head 已入、 /// 根因:协议不变式要求「tool 响应必须紧随其 assistant(tool_calls)」。后台压缩摘要在 head 已入、
/// 结果未回的窗口内楔入,或用户在执行期插话,都会产生 `[head, 异类, tool…]`。原逻辑对此按「未邻接 /// 结果未回的窗口内楔入,或用户在执行期插话,都会产生 `[head, 异类, tool…]`。原逻辑对此按「未邻接
/// = 未闭合」剥除头与其响应,**丢掉整轮工具交互**(有损)。本步改为**归位**——夹层后移,头与其响应 /// = 未闭合」剥除头与其响应,**丢掉整轮工具交互**(有损)。本步改为**归位**——夹层后移,头与其响应
/// 恢复相邻,工具轮保留;夹层内容亦不丢。仅处理「单头 + 夹层 + 紧随该头的连续 tool 段」这一可确定 /// 恢复相邻,工具轮保留;夹层内容亦不丢。
/// 归位的形态;遇到下一个 tool_call 头即停止收集(不跨单元搬运),其余畸形仍由 step2/3 剥除兜底。 ///
/// 覆盖**多夹层**:同一头的响应之间又插了第二个夹层时(如 `[head(c1,c2), 异类, tool(c1), 异类,
/// tool(c2)]`),只后移首夹层会让后段 tool 仍不与头相邻 → step1 邻接判定只认前段 → 后段 call 被
/// 判未闭合而剥除(tool 一并丢)。本实现把「响应集合耗尽前出现的**所有**非 Tool 消息」整体移到
/// **该头最后一个响应之后**,头后紧邻其全部响应(保序),夹层随后。
///
/// 边界:遇下一个 tool_call 头即停(不跨单元搬运);该头响应集合(其 tool_calls 的 id)全部命中
/// 即停(不牵动响应之后的无关消息)。无法归位者(头无响应)仍由 step2/3 剥除兜底。
/// ///
/// 幂等:归位后序列已相邻,再跑本函数无夹层可移,输出不变。 /// 幂等:归位后序列已相邻,再跑本函数无夹层可移,输出不变。
fn reorder_interlopers_after_tool_run(msgs: Vec<ChatMessage>) -> Vec<ChatMessage> { fn gather_tool_responses(msgs: Vec<ChatMessage>) -> Vec<ChatMessage> {
use std::collections::HashSet;
fn is_tool_call_head(m: &ChatMessage) -> bool { fn is_tool_call_head(m: &ChatMessage) -> bool {
matches!(m.role, MessageRole::Assistant) matches!(m.role, MessageRole::Assistant)
&& m.tool_calls.as_ref().is_some_and(|c| !c.is_empty()) && m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())
@@ -226,21 +235,36 @@ fn reorder_interlopers_after_tool_run(msgs: Vec<ChatMessage>) -> Vec<ChatMessage
let mut i = 0usize; let mut i = 0usize;
while i < msgs.len() { while i < msgs.len() {
if is_tool_call_head(&msgs[i]) { if is_tool_call_head(&msgs[i]) {
// 收集头与首个 tool 之间的夹层(遇下一个 tool_call 头即停,不跨单元) // 该头全部 call id —— 用于判定它的响应集合是否已耗尽
let call_ids: HashSet<String> = msgs[i]
.tool_calls
.as_ref()
.map(|c| c.iter().map(|c| c.id.clone()).collect())
.unwrap_or_default();
// 从头后扫到「下一个 tool_call 头」或「该头响应集合耗尽」为止,分流响应与夹层
let mut j = i + 1; let mut j = i + 1;
let mut responses: Vec<ChatMessage> = Vec::new();
let mut interlopers: Vec<ChatMessage> = Vec::new(); let mut interlopers: Vec<ChatMessage> = Vec::new();
while j < msgs.len() && !is_tool(&msgs[j]) && !is_tool_call_head(&msgs[j]) { let mut seen: HashSet<String> = HashSet::new();
while j < msgs.len() && !is_tool_call_head(&msgs[j]) {
if is_tool(&msgs[j]) {
if let Some(id) = msgs[j].tool_call_id.as_deref() {
seen.insert(id.to_string());
}
responses.push(msgs[j].clone());
} else {
interlopers.push(msgs[j].clone()); interlopers.push(msgs[j].clone());
j += 1;
} }
let run_start = j;
while j < msgs.len() && is_tool(&msgs[j]) {
j += 1; j += 1;
// 该头响应集合已全部命中 → 不再牵动响应之后的无关消息(保持边界语义)
if !call_ids.is_empty() && call_ids.iter().all(|id| seen.contains(id)) {
break;
} }
// 有夹层且紧随其后确有 tool 段 → 头 + 响应在前,夹层后移(只重排) }
if !interlopers.is_empty() && run_start < j { // 有夹层且确有响应 → 头 + 全部响应在前,夹层整体后移(只重排)
if !interlopers.is_empty() && !responses.is_empty() {
out.push(msgs[i].clone()); out.push(msgs[i].clone());
out.extend(msgs[run_start..j].iter().cloned()); out.extend(responses);
out.extend(interlopers); out.extend(interlopers);
i = j; i = j;
continue; continue;
@@ -694,6 +718,66 @@ mod tests {
assert_eq!(sig(&once), sig(&twice), "归位应幂等(角色序与内容序不变)"); assert_eq!(sig(&once), sig(&twice), "归位应幂等(角色序与内容序不变)");
} }
/// 多夹层归位:同一头的响应之间又插了第二个夹层时,头与该头的**全部**响应必须聚拢相邻
/// (旧实现只后移首夹层,后段 tool 仍不与头相邻 → step1 判后段 call 未闭合 → 剥除并丢其 tool)。
#[test]
fn sanitize_reorders_multiple_interlopers_to_keep_tool_round() {
// [user] [assistant(c1,c2)] [system 夹层X] [tool c1] [system 夹层Y] [tool c2]
let msgs = vec![
ChatMessage::user("go"),
ChatMessage::assistant_with_tools(
"",
vec![
ToolCall::new("c1".to_string(), "t", "{}"),
ToolCall::new("c2".to_string(), "t", "{}"),
],
),
ChatMessage::system("夹层X"),
ChatMessage::tool_result("c1", "r1"),
ChatMessage::system("夹层Y"),
ChatMessage::tool_result("c2", "r2"),
];
let out = sanitize_messages(msgs);
// 零丢失:6 条全在(归位只重排,不删内容)
assert_eq!(out.len(), 6, "多夹层归位不得丢内容, 实际 {:?}", out);
// 头保留其两个 tool_call(工具轮不丢)
let head_idx = out
.iter()
.position(|m| matches!(m.role, MessageRole::Assistant))
.expect("head 应保留");
assert_eq!(
out[head_idx].tool_calls.as_ref().map(|c| c.len()),
Some(2),
"头应保留两个 tool_call, 实际 {:?}",
out
);
// 头后紧邻其全部响应(保序 c1,c2),协议不变式成立
assert!(
matches!(out[head_idx + 1].role, MessageRole::Tool)
&& out[head_idx + 1].tool_call_id.as_deref() == Some("c1")
&& matches!(out[head_idx + 2].role, MessageRole::Tool)
&& out[head_idx + 2].tool_call_id.as_deref() == Some("c2"),
"head 后应紧邻其全部响应 c1,c2, 实际 {:?}",
out
);
// 两条夹层内容都保留(后移到响应之后)
assert!(
out.iter()
.any(|m| matches!(m.role, MessageRole::System) && m.content == "夹层X")
&& out.iter()
.any(|m| matches!(m.role, MessageRole::System) && m.content == "夹层Y"),
"两条夹层内容不得丢, 实际 {:?}",
out
);
// 幂等:再跑一次结果不变
let twice = sanitize_messages(out.clone());
let sig = |v: &[ChatMessage]| -> Vec<String> {
v.iter().map(|m| format!("{:?}|{}", m.role, m.content)).collect()
};
assert_eq!(sig(&out), sig(&twice), "多夹层归位应幂等(角色序与内容序不变)");
}
#[test] #[test]
fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() { fn sanitize_heals_reverse_orphan_tool_result_with_placeholder_head() {
// 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)。 // 三元组完整性 P0:反向 orphan(tool_result 无对应 tool_call 头)→ 补占位头自愈(不丢)。
-3438
View File
File diff suppressed because it is too large Load Diff
@@ -98,6 +98,7 @@ pub async fn cleanup_expired_approvals(app: &AppHandle, state: &AppState, conv_i
&tc_id, &tc_id,
"rejected", "rejected",
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)), Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
None,
) )
.await; .await;
} }
@@ -35,6 +35,7 @@ use std::sync::Arc;
use tauri::AppHandle; use tauri::AppHandle;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use df_ai::context::ContextManager;
use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary}; use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
use df_ai::provider::ChatMessage; use df_ai::provider::ChatMessage;
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
@@ -59,6 +60,32 @@ const PROTECT_COUNT: usize = 6;
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。 /// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
const KEYWORD_FALLBACK_ENABLED: bool = true; 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 之前预处理。 /// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
/// ///
/// **L0-3(2026-08-09)后台化**:压缩 LLM 调用不再同步阻塞本轮 loop(非流式 LLM 最长 60s)。 /// **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 { emit_ai_chat_event(app_handle, AiChatEvent::AiCompressing {
conversation_id: Some(conv_id.to_string()), 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 mut session = session_arc.lock().await;
let conv = session.conv(&conv_id); let conv = session.conv(&conv_id);
conv.messages.set_compressing(true); conv.messages.set_compressing(true);
@@ -123,6 +150,9 @@ pub(super) async fn maybe_auto_compress(
// 切片前按当时实际长度钳制,防 index out of bounds panic // 切片前按当时实际长度钳制,防 index out of bounds panic
// (后续 compress_old_messages 内部自带 min(len) 钳制,口径一致)。 // (后续 compress_old_messages 内部自带 min(len) 钳制,口径一致)。
let msgs = conv.messages.messages_mut(); 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 cut = protect_start.min(msgs.len());
let active_msgs: Vec<ChatMessage> = msgs[..cut] let active_msgs: Vec<ChatMessage> = msgs[..cut]
.iter() .iter()
@@ -131,7 +161,7 @@ pub(super) async fn maybe_auto_compress(
.collect(); .collect();
let lang = conv.agent_language.clone() let lang = conv.agent_language.clone()
.unwrap_or_else(|| "zh-CN".to_string()); .unwrap_or_else(|| "zh-CN".to_string());
(active_msgs, lang) (active_msgs, lang, head_id)
}; };
// T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。 // T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。
@@ -203,7 +233,15 @@ pub(super) async fn maybe_auto_compress(
let conv_id = conv_id.to_string(); let conv_id = conv_id.to_string();
let provider_config = provider_config.clone(); let provider_config = provider_config.clone();
let llm_concurrency = llm_concurrency.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 { 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)。 // 后台重建 provider(secret::build_provider_for 三步打包:resolve→ensure→build)。
// 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放 // 失败 → 同 LLM 失败降级:复位 is_compressing + 关键词摘要兜底(标 compressed 释放
// token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降, // token + 插关键词),保持「失败→兜底→扣 token」语义——否则 history_tokens 未降,
@@ -227,6 +265,7 @@ pub(super) async fn maybe_auto_compress(
&keyword_fallback, &keyword_fallback,
pre_compress_tokens, pre_compress_tokens,
"压缩 provider 重建失败", "压缩 provider 重建失败",
&head_id,
) )
.await; .await;
return; return;
@@ -256,6 +295,16 @@ pub(super) async fn maybe_auto_compress(
return; return;
} }
let conv = session.conv(&conv_id); 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); let _compressed = conv.messages.compress_old_messages(protect_start);
// NL 摘要尾部追加(而非 insert_at(0) 头部插入):头部插入会位移整条 // NL 摘要尾部追加(而非 insert_at(0) 头部插入):头部插入会位移整条
// 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效 // 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效
@@ -284,7 +333,11 @@ pub(super) async fn maybe_auto_compress(
// 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。 // 单次锁内 check+mutate(防 conv 在两次锁间被删致惰性重建污染新 conv)。
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) { 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) => { Err(e) => {
@@ -316,10 +369,21 @@ pub(super) async fn maybe_auto_compress(
&keyword_fallback, &keyword_fallback,
pre_compress_tokens, pre_compress_tokens,
"压缩失败", "压缩失败",
&head_id,
) )
.await; .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 已删(per_conv 无条目)→ 直接返回(标志随 conv 销毁无需复位;对齐原 return 语义:
/// 防惰性重建污染新 conv)。 /// 防惰性重建污染新 conv)。
///
/// **内容版本守卫(939⑪)**:`head_id` 为触发时快照的首条消息 id;提交前比对不符(期间
/// `clear()` 重建致上下文整体替换)→ 直接返回:既不标 compressed / 不插旧关键词摘要污染
/// 新上下文,也不复位 is_compressing(clear 已复位;复位会误伤新会话可能已启的新压缩信令)。
async fn keyword_fallback_commit( async fn keyword_fallback_commit(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str, conv_id: &str,
@@ -348,6 +416,7 @@ async fn keyword_fallback_commit(
keyword_fallback: &str, keyword_fallback: &str,
pre_tokens: u32, pre_tokens: u32,
log_head: &str, log_head: &str,
head_id: &Option<String>,
) { ) {
let inserted = { let inserted = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
@@ -355,6 +424,15 @@ async fn keyword_fallback_commit(
return; return;
} }
let conv = session.conv(conv_id); 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①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。 // 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。
conv.messages.set_compressing(false); conv.messages.set_compressing(false);
if !KEYWORD_FALLBACK_ENABLED { 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 必须复位,否则压缩永久停摆"
);
}
}
+112 -5
View File
@@ -46,6 +46,8 @@ use super::conversation::{save_conversation, TokenAccumulator};
use super::knowledge_inject::{maybe_spawn_extraction}; use super::knowledge_inject::{maybe_spawn_extraction};
use super::title::{ensure_conversation_title, spawn_ensure_title}; use super::title::{ensure_conversation_title, spawn_ensure_title};
use super::{AiChatEvent, AiSession, ErrorType}; use super::{AiChatEvent, AiSession, ErrorType};
// B5a 挂钟闸分任务定级:轮间闸读会话策略(未注册落 legacy 硬停,确定性任务检查点续跑)。
use super::unattended::{wall_clock_policy_for, WallClockAction};
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。 // ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。 /// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。
@@ -102,6 +104,8 @@ pub const ROUND_REENTRY_BACKOFF_SECS: [u64; ROUND_REENTRY_MAX] = [60, 300, 900];
/// 无人值守长任务(max_iterations=0 不限轮次)缺 token/挂钟兜底——模型死循环级输出 /// 无人值守长任务(max_iterations=0 不限轮次)缺 token/挂钟兜底——模型死循环级输出
/// 白烧预算无人踩闸。三闸在每轮头检查(轮间粒度,单轮内不中断): /// 白烧预算无人踩闸。三闸在每轮头检查(轮间粒度,单轮内不中断):
/// ①挂钟:超 UNATTENDED_WALL_SECS(默认 30min,对齐委托任务超时底座)停靠 wall_clock; /// ①挂钟:超 UNATTENDED_WALL_SECS(默认 30min,对齐委托任务超时底座)停靠 wall_clock;
/// 未注册分档的会话落此 legacy 阈值;已注册会话由 unattended::wall_clock_policy_for 的
/// 策略决定(确定性任务放宽+检查点续跑,探索性/失控疑似硬停),见下方挂钟闸;
/// ②累计 token:超 UNATTENDED_MAX_TOKENS(默认 5M)停靠 token_budget; /// ②累计 token:超 UNATTENDED_MAX_TOKENS(默认 5M)停靠 token_budget;
/// ③轮次:max_iterations>0 既有 for 上界 + 达上限停靠分支,不重复设闸。 /// ③轮次:max_iterations>0 既有 for 上界 + 达上限停靠分支,不重复设闸。
/// 超限走既有 emit_task_parked——前端词表已预留,零新契约。 /// 超限走既有 emit_task_parked——前端词表已预留,零新契约。
@@ -1163,8 +1167,9 @@ pub(crate) async fn run_agentic_loop(
// 无注入扩轮时动态判定与原上界等价,行为零变化。扩轮总数封顶防注入洪水无限续跑。 // 无注入扩轮时动态判定与原上界等价,行为零变化。扩轮总数封顶防注入洪水无限续跑。
let mut injected_extra_rounds: usize = 0; let mut injected_extra_rounds: usize = 0;
// H2 token 三闸基点:loop 启动挂钟起点(轮间粒度检查,单轮不中断) // H2 token 三闸基点:loop 启动挂钟起点(轮间粒度检查,单轮不中断)。
let loop_started = tokio::time::Instant::now(); // 可变:确定性任务到点走检查点续跑时顺延起点(见下方挂钟闸)。
let mut loop_started = tokio::time::Instant::now();
// H3 外部干预感知基点:loop 启动时在被监视工作区取 git HEAD 快照(轮间粒度比对)。 // H3 外部干预感知基点:loop 启动时在被监视工作区取 git HEAD 快照(轮间粒度比对)。
// 设计口径:loop_epoch 管「引擎内接管」,H3 管「引擎外干预」——用户/另一 AI 会话在 loop // 设计口径:loop_epoch 管「引擎内接管」,H3 管「引擎外干预」——用户/另一 AI 会话在 loop
@@ -1219,10 +1224,30 @@ pub(crate) async fn run_agentic_loop(
{ {
let elapsed = loop_started.elapsed().as_secs(); let elapsed = loop_started.elapsed().as_secs();
let total_tokens = tokens.total() as u64; let total_tokens = tokens.total() as u64;
let over = if elapsed >= UNATTENDED_WALL_SECS { // ── 挂钟闸(B5a):读会话策略决定阈值与到点动作 ──
// None(未注册/分档关)→ legacy 30min 硬停,零行为变更;
// CheckpointContinue(确定性任务)→ 到点存进度 + 顺延挂钟起点后本轮续跑,不停工;
// HardPark(探索性/失控疑似)→ 维持原 emit_task_parked(wall_clock) + teardown。
let wall_policy = wall_clock_policy_for(&conv_id);
let wall_deadline = wall_policy.map_or(UNATTENDED_WALL_SECS, |p| p.deadline_secs);
let mut wall_exceeded = elapsed >= wall_deadline;
if wall_exceeded
&& matches!(wall_policy.map(|p| p.action), Some(WallClockAction::CheckpointContinue))
{
// 检查点续跑:落库当前进度(不等 loop 出口),挂钟起点顺延,本轮继续。
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
save_conversation(&session_arc, &db, &conv_id, Some(&save_usage), Some(&resolved_model), true).await;
loop_started = tokio::time::Instant::now();
wall_exceeded = false;
tracing::info!(
conv_id = %conv_id, elapsed, deadline = wall_deadline,
"[ai] 挂钟到点,检查点续跑(确定性任务)"
);
}
let over = if wall_exceeded {
Some(( Some((
"wall_clock", "wall_clock",
format!("任务运行超 {} 分钟,安全停靠:已完成进度已保存,点继续或重发消息可续跑", UNATTENDED_WALL_SECS / 60), format!("任务运行超 {} 分钟,安全停靠:已完成进度已保存,点继续或重发消息可续跑", wall_deadline / 60),
)) ))
} else if total_tokens >= UNATTENDED_MAX_TOKENS { } else if total_tokens >= UNATTENDED_MAX_TOKENS {
Some(( Some((
@@ -1364,9 +1389,13 @@ pub(crate) async fn run_agentic_loop(
"[ai] 连续 {} 轮失败,重入预算耗尽,任务停靠", "[ai] 连续 {} 轮失败,重入预算耗尽,任务停靠",
consecutive_round_failures, consecutive_round_failures,
); );
// 停靠语义:超时类(上游收了请求不响应,非确定性)与其他 Fatal 走
// 不同文案——超时突出「可继续」,由停靠卡「继续」一键续跑而非中断;
// 其余 Fatal 维持原连续失败文案(确定性错误,续跑无益)。
let reason = fatal_park_reason(consecutive_round_failures, &error);
crate::commands::ai::emit_task_parked( crate::commands::ai::emit_task_parked(
&app_handle, &conv_id, &app_handle, &conv_id,
&format!("模型调用连续 {} 轮失败(重入退避后仍未恢复),任务安全停靠:{}", consecutive_round_failures, error), &reason,
"fatal", "fatal",
); );
return; return;
@@ -2778,6 +2807,34 @@ fn classify_permanent_provider_error(error: &str) -> bool {
model_missing || no_access || param_reject model_missing || no_access || param_reject
} }
// ── is_timeout_error: 超时类失败判定(首字节超时/流式超时/调用超时) ──
// 超时是「上游收了请求却不响应」的非确定性失败:重入退避后仍未恢复时,续跑可能因上游恢复
// 而成功,故落停靠语义给一键「继续」,而非让用户只见裸错误文案后中断。与之相对,4xx/鉴权/
// 参数类 Fatal 是确定性错误(改配置才有解,续跑无益),不套用本判定——判定只读错误文本的
// 超时特征(引擎各超时出口文案含「超时」或 diag 包装的 timeout 分类)。
fn is_timeout_error(error: &str) -> bool {
error.to_lowercase().contains("timeout") || error.contains("超时")
}
/// 重入预算耗尽时的停靠文案决策(纯函数,便于单测锁定「超时类 → 停靠」契约)。
///
/// 超时类走「超时 + 可续跑」文案(点明可一键继续);其余 Fatal 维持原「连续失败」文案。
/// stop_kind 均用既有 "fatal"(不新增停靠族:前端 stop_kind 词表/i18n 未同步,新值会退化
/// 为中性兜底标签;超时与其他 Fatal 的区分落在文案语义,卡片「继续」按钮两者皆已具备)。
fn fatal_park_reason(failures: usize, error: &str) -> String {
if is_timeout_error(error) {
format!(
"模型响应连续 {} 轮超时(上游收了请求未及时响应,重入退避后仍未恢复),任务安全停靠:已完成进度已保存,点「继续」可基于当前上下文续跑",
failures
)
} else {
format!(
"模型调用连续 {} 轮失败(重入退避后仍未恢复),任务安全停靠:{}",
failures, error
)
}
}
// ── emit_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ── // ── emit_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ──
// 双写经 emit_ai_chat_event。error_type: 可判定的永久性 provider 错误(模型不存在/令牌无权/ // 双写经 emit_ai_chat_event。error_type: 可判定的永久性 provider 错误(模型不存在/令牌无权/
// 参数拒绝类 400)归 ProviderConfig,其余维持 Network 现状(2026-10-01 两端统一时的口径)。 // 参数拒绝类 400)归 ProviderConfig,其余维持 Network 现状(2026-10-01 两端统一时的口径)。
@@ -3502,6 +3559,56 @@ mod round_reentry_tests {
} }
} }
// 902 契约:超时类失败在轮次重入预算耗尽时落停靠语义(而非只留裸错误文案中断)。
// 停靠文案由纯函数 fatal_park_reason 决策,本测试锁「超时类 → 超时+可续跑文案 /
// 非超时 Fatal → 原连续失败文案」两分支(完整 loop 依赖 Tauri 运行时,契约由纯函数锁定)。
#[cfg(test)]
mod fatal_park_tests {
use super::{fatal_park_reason, is_timeout_error};
#[test]
fn timeout_class_recognized() {
// 首字节超时(stream_recv 出口文案,全角括号)
assert!(is_timeout_error(
"流式响应超时(10秒未收到首字节,服务无响应或请求体过大)"
));
// 专用线程 120s 硬超时
assert!(is_timeout_error("LLM 调用超时(120s)"));
// diag 包装的英文分类(timeout)
assert!(is_timeout_error(
"[u-switch] 流式接收错误(timeout): request timed out"
));
// 非超时:4xx/鉴权/模型不存在不误判
assert!(!is_timeout_error(
"[x] AI 调用失败(HTTP 401): unauthorized"
));
assert!(!is_timeout_error("model is not found"));
}
#[test]
fn timeout_exhaustion_parks_with_continue_guidance() {
let reason = fatal_park_reason(4, "LLM 调用超时(120s)");
assert!(reason.contains("超时"), "超时类停靠文案须点明超时: {reason}");
assert!(
reason.contains("继续"),
"超时类停靠文案须给一键「继续」指引: {reason}"
);
}
#[test]
fn non_timeout_fatal_keeps_generic_reason() {
let reason = fatal_park_reason(4, "[x] AI 调用失败(HTTP 404): model not found");
assert!(
reason.contains("连续 4 轮失败"),
"非超时 Fatal 维持原连续失败文案: {reason}"
);
assert!(
reason.contains("model not found"),
"原诊断文本须保留: {reason}"
);
}
}
// 末轮注入扩轮(todo.md 28)loop 侧契约:到达原轮次终点时,注入侧顺延的余量必须被消费 // 末轮注入扩轮(todo.md 28)loop 侧契约:到达原轮次终点时,注入侧顺延的余量必须被消费
// 续跑(「末轮注入→有续轮」),无余量则收尾(与原 for 自然结束等价,零行为变化)。 // 续跑(「末轮注入→有续轮」),无余量则收尾(与原 for 自然结束等价,零行为变化)。
// 与注入侧判定 injection_hits_final_round(commands/ai/commands/chat.rs)分半锁同一契约。 // 与注入侧判定 injection_hits_final_round(commands/ai/commands/chat.rs)分半锁同一契约。
+3 -3
View File
@@ -182,7 +182,7 @@ pub(super) async fn insert_pending_approval(
conversation_id: Some(conv_id.to_string()), conversation_id: Some(conv_id.to_string()),
}; };
emit_ai_chat_event(&app_handle, ev); emit_ai_chat_event(&app_handle, ev);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id).await; audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id, None).await;
return; return;
} }
@@ -224,7 +224,7 @@ pub(super) async fn insert_pending_approval(
"等待工具审批", "等待工具审批",
&format!("工具 {} 已暂停,批准后继续执行", draft.name), &format!("工具 {} 已暂停,批准后继续执行", draft.name),
); );
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await; audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id, None).await;
} }
/// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 (会话, 工具, 参数) 重试计数。 /// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 (会话, 工具, 参数) 重试计数。
@@ -366,7 +366,7 @@ pub(super) async fn handle_approval_tool(
}; };
emit_ai_chat_event(&app_handle, ev); emit_ai_chat_event(&app_handle, ev);
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,;decided_by=auto_dedup),不进 pending // 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,;decided_by=auto_dedup),不进 pending
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(warned_content), Some("auto_dedup"), current_message_id).await; audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(warned_content), Some("auto_dedup"), current_message_id, None).await;
return; return;
} }
} }
+14 -2
View File
@@ -4,6 +4,7 @@
//! re-export 经 audit/mod.rs `pub(crate) use finalize::audit_finalize;` 保持 //! re-export 经 audit/mod.rs `pub(crate) use finalize::audit_finalize;` 保持
//! `commands::ai::audit::*` 路径透明(调用方零变更)。 //! `commands::ai::audit::*` 路径透明(调用方零变更)。
use crate::commands::ai::tool_result_offload;
use crate::commands::now_millis; use crate::commands::now_millis;
use crate::state::AppState; use crate::state::AppState;
@@ -12,7 +13,13 @@ use crate::state::AppState;
/// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC, /// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC,
/// 而 ai_tool_executions 无该列,调用会报 "no such column: created_at" 被 unwrap_or_default 吞掉, /// 而 ai_tool_executions 无该列,调用会报 "no such column: created_at" 被 unwrap_or_default 吞掉,
/// 导致审批后审计记录永久卡 pending。 /// 导致审批后审计记录永久卡 pending。
pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status: &str, result: Option<String>) { pub(crate) async fn audit_finalize(
state: &AppState,
tool_call_id: &str,
status: &str,
result: Option<String>,
duration_ms: Option<i64>,
) {
// 区分 Err(DB 故障)与 Ok(None)(真无记录):原 unwrap_or_default 把 Err 压成 None, // 区分 Err(DB 故障)与 Ok(None)(真无记录):原 unwrap_or_default 把 Err 压成 None,
// DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索。 // DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索。
let mut rec = match state.ai_tool_executions.find_by_tool_call_id(tool_call_id).await { let mut rec = match state.ai_tool_executions.find_by_tool_call_id(tool_call_id).await {
@@ -29,8 +36,13 @@ pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status:
rec.status = status.to_string(); rec.status = status.to_string();
rec.decided_by = Some("human".to_string()); rec.decided_by = Some("human".to_string());
rec.executed_at = Some(now_millis()); rec.executed_at = Some(now_millis());
// V50 纯执行耗时:审批路径由调用方(ai_approve/ai_authorize_dir)在真执行窗口测量后传入,
// 与「requested_at→executed_at」(含等待人工批准)解耦。拒绝/超时未执行传 None。
rec.duration_ms = duration_ms;
if let Some(r) = result { if let Some(r) = result {
rec.result = Some(r); // 与 audit_tool_call 的写侧一致:超阈值结果外置落盘,DB 只留句柄,防审批路径大结果
// 绕过外置直接内联落库(工具真正执行发生在审批通过之后,这条才是大结果主入口)。
rec.result = Some(tool_result_offload::offload_result(tool_call_id, &rec.tool_name, &r));
} }
if let Err(e) = state.ai_tool_executions.update_full(&rec).await { if let Err(e) = state.ai_tool_executions.update_full(&rec).await {
tracing::error!("audit_finalize: 回填 tool_call_id={} 审计状态失败: {}", tool_call_id, e); tracing::error!("audit_finalize: 回填 tool_call_id={} 审计状态失败: {}", tool_call_id, e);
+35 -24
View File
@@ -164,7 +164,7 @@ async fn deny_sub_agent_tool(
}; };
emit_ai_chat_event(app_handle, ev); emit_ai_chat_event(app_handle, ev);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id).await; "rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id, None).await;
} }
/// 任务边界拒绝单点(无人值守,设计 §三):结构化失败 tool_result + emit Completed + 审计留痕。 /// 任务边界拒绝单点(无人值守,设计 §三):结构化失败 tool_result + emit Completed + 审计留痕。
@@ -212,7 +212,7 @@ async fn deny_boundary_tool(
}; };
emit_ai_chat_event(app_handle, ev); emit_ai_chat_event(app_handle, ev);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"rejected", risk_level, Some(msg), Some(layer), current_message_id).await; "rejected", risk_level, Some(msg), Some(layer), current_message_id, None).await;
} }
/// P2-1 执行期白名单判定(纯函数可单测):SubAgent 策略下工具是否被白名单裁掉。 /// P2-1 执行期白名单判定(纯函数可单测):SubAgent 策略下工具是否被白名单裁掉。
@@ -615,6 +615,7 @@ async fn cancel_expired_approval(
tc_id, tc_id,
"rejected", "rejected",
Some(format!("审批超时({}分钟)自动取消", timeout_minutes)), Some(format!("审批超时({}分钟)自动取消", timeout_minutes)),
None,
) )
.await; .await;
// 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失) // 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失)
@@ -747,7 +748,7 @@ pub(crate) async fn process_tool_calls(
emit_ai_chat_event(&app_handle, ev); emit_ai_chat_event(&app_handle, ev);
let risk = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); let risk = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"failed", risk, Some(err_payload.clone()), Some("auto_resolve_fail"), current_message_id).await; "failed", risk, Some(err_payload.clone()), Some("auto_resolve_fail"), current_message_id, None).await;
} }
} }
} }
@@ -893,7 +894,7 @@ pub(crate) async fn process_tool_calls(
audit_tool_call( audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"rejected", risk_level, Some(err_msg), Some("auto_blacklist"), "rejected", risk_level, Some(err_msg), Some("auto_blacklist"),
current_message_id, current_message_id, None,
).await; ).await;
} }
@@ -952,7 +953,7 @@ pub(crate) async fn process_tool_calls(
audit_tool_call( audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"),
current_message_id, current_message_id, None,
).await; ).await;
continue; continue;
} }
@@ -991,7 +992,7 @@ pub(crate) async fn process_tool_calls(
audit_tool_call( audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"pending", risk_level, None, None, "pending", risk_level, None, None,
current_message_id, current_message_id, None,
).await; ).await;
} }
@@ -1126,13 +1127,16 @@ pub(crate) async fn process_tool_calls(
// 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间。P0 重构后锁外并行,根本消除阻塞。 // 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间。P0 重构后锁外并行,根本消除阻塞。
// join_all 保序——结果顺序 = trust_hits 输入顺序 = tc_list 原始 index 顺序,不额外 sort // join_all 保序——结果顺序 = trust_hits 输入顺序 = tc_list 原始 index 顺序,不额外 sort
if !trust_hits.is_empty() { if !trust_hits.is_empty() {
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>)> = let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>, i64)> =
futures::future::join_all(trust_hits.into_iter().map(|(draft, args, _dir_label, risk_level)| { futures::future::join_all(trust_hits.into_iter().map(|(draft, args, _dir_label, risk_level)| {
let tools = tools_arc.clone(); let tools = tools_arc.clone();
let app_clone = app_handle.clone(); let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string(); let conv_clone = conv_id.to_string();
async move { async move {
// V50 纯执行耗时:本闭包内逐工具测真执行墙钟(并行下各工具独立,非批 wall)。
let exec_started = std::time::Instant::now();
let exec_result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await; let exec_result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await;
let exec_ms = exec_started.elapsed().as_millis() as i64;
match exec_result { match exec_result {
Ok(val) => { Ok(val) => {
let content = val.to_string(); let content = val.to_string();
@@ -1146,7 +1150,7 @@ pub(crate) async fn process_tool_calls(
// 数据变更类工具执行成功后 emit df-data-changed 联动刷新 // 数据变更类工具执行成功后 emit df-data-changed 联动刷新
// (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop) // (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop)
emit_data_changed(&app_clone, &draft.name); emit_data_changed(&app_clone, &draft.name);
(draft, risk_level, Ok(content)) (draft, risk_level, Ok(content), exec_ms)
} }
Err(e) => { Err(e) => {
let content = e.to_string(); let content = e.to_string();
@@ -1157,7 +1161,7 @@ pub(crate) async fn process_tool_calls(
conversation_id: Some(conv_clone), conversation_id: Some(conv_clone),
}; };
emit_ai_chat_event(&app_clone, ev); emit_ai_chat_event(&app_clone, ev);
(draft, risk_level, Err(content)) (draft, risk_level, Err(content), exec_ms)
} }
} }
} }
@@ -1165,7 +1169,7 @@ pub(crate) async fn process_tool_calls(
// 串行回填 tool_result + 审计(短 lock push + 锁外 audit) // 串行回填 tool_result + 审计(短 lock push + 锁外 audit)
for (draft, risk_level, outcome) in results { for (draft, risk_level, outcome, exec_ms) in results {
let (status, content) = match outcome { let (status, content) = match outcome {
Ok(c) => ("completed", c), Ok(c) => ("completed", c),
Err(c) => ("failed", c), Err(c) => ("failed", c),
@@ -1193,7 +1197,7 @@ pub(crate) async fn process_tool_calls(
} }
} }
// 锁外:审计(trust 放行仍记一条,decided_by=auto_trust),留痕可追溯 // 锁外:审计(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; audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust"), current_message_id, Some(exec_ms)).await;
} }
} }
@@ -1256,7 +1260,7 @@ pub(crate) async fn process_tool_calls(
audit_tool_call( audit_tool_call(
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
"completed", risk_level, Some(warned_content), Some("cache_hit"), "completed", risk_level, Some(warned_content), Some("cache_hit"),
current_message_id, current_message_id, None,
).await; ).await;
tracing::info!( tracing::info!(
conv_id = %conv_id, conv_id = %conv_id,
@@ -1272,13 +1276,16 @@ pub(crate) async fn process_tool_calls(
} }
let low_risk = low_risk_uncached; let low_risk = low_risk_uncached;
// 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。 // 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。
let results: Vec<(ToolCallDraft, serde_json::Value, RiskLevel, Result<String, String>)> = let results: Vec<(ToolCallDraft, serde_json::Value, RiskLevel, Result<String, String>, i64)> =
futures::future::join_all(low_risk.into_iter().map(|(draft, args, risk_level)| { futures::future::join_all(low_risk.into_iter().map(|(draft, args, risk_level)| {
let tools = tools_arc.clone(); let tools = tools_arc.clone();
let app_clone = app_handle.clone(); let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string(); let conv_clone = conv_id.to_string();
async move { async move {
// V50 纯执行耗时:闭包内逐工具测真执行墙钟(并行各自独立)。
let exec_started = std::time::Instant::now();
let result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await; let result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await;
let exec_ms = exec_started.elapsed().as_millis() as i64;
match result { match result {
Ok(val) => { Ok(val) => {
// L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。 // L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。
@@ -1291,7 +1298,7 @@ pub(crate) async fn process_tool_calls(
// AR-11(方案A):数据变更工具执行成功后 emit df-data-changed, // AR-11(方案A):数据变更工具执行成功后 emit df-data-changed,
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。 // 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
emit_data_changed(&app_clone, &draft.name); emit_data_changed(&app_clone, &draft.name);
(draft, val.clone(), risk_level, Ok(val.to_string())) (draft, val.clone(), risk_level, Ok(val.to_string()), exec_ms)
} }
Err(e) => { Err(e) => {
// AR-6(定向B):Low 工具失败不 emit AiError。 // AR-6(定向B):Low 工具失败不 emit AiError。
@@ -1307,7 +1314,7 @@ pub(crate) async fn process_tool_calls(
conversation_id: Some(conv_clone), conversation_id: Some(conv_clone),
}; };
emit_ai_chat_event(&app_clone, ev); emit_ai_chat_event(&app_clone, ev);
(draft, serde_json::Value::Null, risk_level, Err(err_msg)) (draft, serde_json::Value::Null, risk_level, Err(err_msg), exec_ms)
} }
} }
} }
@@ -1319,7 +1326,7 @@ pub(crate) async fn process_tool_calls(
// 砍 N 次串行 INSERT 尾巴(治 aichat 效率走查 T1-1)。审计失败不阻断主流程(记日志)。 // 砍 N 次串行 INSERT 尾巴(治 aichat 效率走查 T1-1)。审计失败不阻断主流程(记日志)。
let mut audit_records: Vec<df_storage::models::AiToolExecutionRecord> = let mut audit_records: Vec<df_storage::models::AiToolExecutionRecord> =
Vec::with_capacity(results.len()); Vec::with_capacity(results.len());
for (draft, raw_result, risk_level, outcome) in results { for (draft, raw_result, risk_level, outcome, exec_ms) in results {
let (status, content) = match outcome { let (status, content) = match outcome {
Ok(c) => ("completed", c), Ok(c) => ("completed", c),
Err(c) => ("failed", c), Err(c) => ("failed", c),
@@ -1370,7 +1377,7 @@ pub(crate) async fn process_tool_calls(
} }
audit_records.push(build_audit_record( audit_records.push(build_audit_record(
conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level,
Some(content), Some(decided_by), current_message_id, Some(content), Some(decided_by), current_message_id, Some(exec_ms),
)); ));
} }
// 单事务批量 INSERT(一次 spawn_blocking,砍 N 次串行尾巴;失败仅记日志不阻断)。 // 单事务批量 INSERT(一次 spawn_blocking,砍 N 次串行尾巴;失败仅记日志不阻断)。
@@ -1422,7 +1429,7 @@ pub(crate) async fn process_tool_calls(
dupe_audit_records.push(build_audit_record( dupe_audit_records.push(build_audit_record(
conv_id, &draft.id, &draft.name, &draft.args, conv_id, &draft.id, &draft.name, &draft.args,
"completed", risk_level, Some(warned_content), Some("cache_hit"), "completed", risk_level, Some(warned_content), Some("cache_hit"),
current_message_id, current_message_id, None,
)); ));
} }
if !dupe_audit_records.is_empty() { if !dupe_audit_records.is_empty() {
@@ -1450,13 +1457,17 @@ pub(crate) async fn process_tool_calls(
"[ai] 任务边界放行 {} 个工具进入并行执行(decided_by=boundary_auto)", "[ai] 任务边界放行 {} 个工具进入并行执行(decided_by=boundary_auto)",
boundary_auto.len() boundary_auto.len()
); );
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>)> = let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>, i64)> =
futures::future::join_all(boundary_auto.into_iter().map(|(draft, args, risk_level)| { futures::future::join_all(boundary_auto.into_iter().map(|(draft, args, risk_level)| {
let tools = tools_arc.clone(); let tools = tools_arc.clone();
let app_clone = app_handle.clone(); let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string(); let conv_clone = conv_id.to_string();
async move { async move {
match execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await { // V50 纯执行耗时:闭包内逐工具测真执行墙钟。
let exec_started = std::time::Instant::now();
let exec_result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await;
let exec_ms = exec_started.elapsed().as_millis() as i64;
match exec_result {
Ok(val) => { Ok(val) => {
// L3 emit 双写:边界放行工具执行成功 emit Completed 双路发布。 // L3 emit 双写:边界放行工具执行成功 emit Completed 双路发布。
let ev = AiChatEvent::AiToolCallCompleted { let ev = AiChatEvent::AiToolCallCompleted {
@@ -1466,7 +1477,7 @@ pub(crate) async fn process_tool_calls(
}; };
emit_ai_chat_event(&app_clone, ev); emit_ai_chat_event(&app_clone, ev);
emit_data_changed(&app_clone, &draft.name); emit_data_changed(&app_clone, &draft.name);
(draft, risk_level, Ok(val.to_string())) (draft, risk_level, Ok(val.to_string()), exec_ms)
} }
Err(e) => { Err(e) => {
// AR-6 同款:失败包进 tool_result 让 LLM 自纠,不 emit AiError 不断轮。 // AR-6 同款:失败包进 tool_result 让 LLM 自纠,不 emit AiError 不断轮。
@@ -1477,13 +1488,13 @@ pub(crate) async fn process_tool_calls(
conversation_id: Some(conv_clone), conversation_id: Some(conv_clone),
}; };
emit_ai_chat_event(&app_clone, ev); emit_ai_chat_event(&app_clone, ev);
(draft, risk_level, Err(err_msg)) (draft, risk_level, Err(err_msg), exec_ms)
} }
} }
} }
})).await; })).await;
for (draft, risk_level, outcome) in results { for (draft, risk_level, outcome, exec_ms) in results {
let (status, content) = match outcome { let (status, content) = match outcome {
Ok(c) => ("completed", c), Ok(c) => ("completed", c),
Err(c) => ("failed", c), Err(c) => ("failed", c),
@@ -1507,7 +1518,7 @@ pub(crate) async fn process_tool_calls(
// 锁外审计:risk 真实等级透传(F-#97 防失真),decided_by=boundary_auto // 锁外审计:risk 真实等级透传(F-#97 防失真),decided_by=boundary_auto
// 标注授权来自任务边界(无人值守事后可全量追溯)。 // 标注授权来自任务边界(无人值守事后可全量追溯)。
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
status, risk_level, Some(content), Some("boundary_auto"), current_message_id).await; status, risk_level, Some(content), Some("boundary_auto"), current_message_id, Some(exec_ms)).await;
} }
} }
+17 -2
View File
@@ -40,6 +40,7 @@ pub(crate) fn build_audit_record(
result: Option<String>, result: Option<String>,
decided_by: Option<&str>, decided_by: Option<&str>,
message_id: Option<&str>, message_id: Option<&str>,
duration_ms: Option<i64>,
) -> AiToolExecutionRecord { ) -> AiToolExecutionRecord {
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None }; let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
// D2a 结果外置:超 8KB 的审计 result 落盘留句柄,治 DB 膨胀(devflow.db 193.7MB 实证)。 // D2a 结果外置:超 8KB 的审计 result 落盘留句柄,治 DB 膨胀(devflow.db 193.7MB 实证)。
@@ -66,6 +67,10 @@ pub(crate) fn build_audit_record(
requested_at: now_millis(), requested_at: now_millis(),
executed_at, executed_at,
decided_by: decided_by.map(|s| s.to_string()), decided_by: decided_by.map(|s| s.to_string()),
// V50 纯执行耗时:由调用方在真执行路径测量后传入(执行前→执行后墙钟);
// 未真执行(deny/pending/缓存命中)传 None。与 requested_at→executed_at 解耦,
// 后者在审批路径混入等待人工批准时长。
duration_ms,
} }
} }
@@ -88,10 +93,11 @@ pub(crate) async fn audit_tool_call(
result: Option<String>, result: Option<String>,
decided_by: Option<&str>, decided_by: Option<&str>,
message_id: Option<&str>, message_id: Option<&str>,
duration_ms: Option<i64>,
) { ) {
let record = build_audit_record( let record = build_audit_record(
conv_id, tool_call_id, tool_name, arguments, conv_id, tool_call_id, tool_name, arguments,
status, risk_level, result, decided_by, message_id, status, risk_level, result, decided_by, message_id, duration_ms,
); );
if let Err(e) = repo.insert(record).await { if let Err(e) = repo.insert(record).await {
tracing::error!( tracing::error!(
@@ -119,7 +125,8 @@ pub(crate) async fn record_audit(
decided_by: Option<&str>, decided_by: Option<&str>,
message_id: Option<&str>, message_id: Option<&str>,
) { ) {
audit_tool_call(repo, conv_id, tool_call_id, tool_name, arguments, status, risk_level, result, decided_by, message_id).await; // 事件总线旁路(无真执行计时),duration_ms 传 None。
audit_tool_call(repo, conv_id, tool_call_id, tool_name, arguments, status, risk_level, result, decided_by, message_id, None).await;
} }
/// 查询审计历史记录(分页,按 requested_at 倒序)。 /// 查询审计历史记录(分页,按 requested_at 倒序)。
@@ -159,6 +166,10 @@ pub struct ToolExecutionDto {
pub executed_at: Option<String>, pub executed_at: Option<String>,
/// human/auto,None 表示尚未决策 /// human/auto,None 表示尚未决策
pub decided_by: Option<String>, pub decided_by: Option<String>,
/// 纯执行耗时(毫秒,V50):真执行墙钟,与审批等待解耦;None=未执行/老数据。
/// 前端审计面板可用此替代 executed_at-requested_at(后者含审批等待)。
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<i64>,
} }
/// 审批历史查询入参(前端透传,空值=不过滤)。 /// 审批历史查询入参(前端透传,空值=不过滤)。
@@ -247,6 +258,7 @@ pub async fn list_tool_executions(
requested_at: r.requested_at, requested_at: r.requested_at,
executed_at: r.executed_at, executed_at: r.executed_at,
decided_by: r.decided_by, decided_by: r.decided_by,
duration_ms: r.duration_ms,
}) })
.collect(); .collect();
let has_more = (offset as i64 + items.len() as i64) < total; let has_more = (offset as i64 + items.len() as i64) < total;
@@ -522,6 +534,7 @@ mod tests {
requested_at: t.to_string(), requested_at: t.to_string(),
executed_at: None, executed_at: None,
decided_by: None, decided_by: None,
duration_ms: None,
}) })
.await .await
.expect("测试数据插入应成功"); .expect("测试数据插入应成功");
@@ -591,6 +604,7 @@ mod tests {
requested_at: t.to_string(), requested_at: t.to_string(),
executed_at: Some(t.to_string()), executed_at: Some(t.to_string()),
decided_by: Some("auto".to_string()), decided_by: Some("auto".to_string()),
duration_ms: None,
}) })
.await .await
.expect("测试数据插入应成功"); .expect("测试数据插入应成功");
@@ -618,6 +632,7 @@ mod tests {
origin: origin.map(|s| s.to_string()), origin: origin.map(|s| s.to_string()),
boundary_json: None, boundary_json: None,
loop_state: None, loop_state: None,
status: "active".to_string(),
}) })
.await .await
.expect("测试会话插入应成功"); .expect("测试会话插入应成功");
+92 -4
View File
@@ -14,7 +14,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。 // 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。
// 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。 // 池空/无匹配兜底 default_model(与 title.rs 一致,行为可预期)。
use df_ai::router::{ use df_ai::router::{
is_likely_image_model, select_model_id, Modality, TaskRequirements, is_likely_image_model, select_model_id, Modality, ModelConfig, TaskRequirements,
}; };
use df_storage::models::AiProviderRecord; use df_storage::models::AiProviderRecord;
@@ -72,15 +72,20 @@ pub(crate) async fn compress_via_llm(
let active_msgs = df_ai::context::ContextManager::sanitize_messages(active_msgs); let active_msgs = df_ai::context::ContextManager::sanitize_messages(active_msgs);
// 路由选模型:无 tool_use(纯文本摘要)。 // 路由选模型:无 tool_use(纯文本摘要)。
// None(池空/无匹配)→ 兜底 default_model(行为不变)。 // None(池空/无匹配)→ 兜底 default_model,但须校验其 enabled(停用则回落池内启用模型,
// 见 resolve_fallback_model);池内无任何启用模型 → Err 让调用方降级,不再静默用停用模型。
let compress_req = TaskRequirements { let compress_req = TaskRequirements {
modalities: vec![Modality::Text], modalities: vec![Modality::Text],
needs_tool_use: false, needs_tool_use: false,
estimated_context: 0, estimated_context: 0,
tier: None, tier: None,
}; };
let model = select_model_id(&compress_req, &provider_config.model_configs) let model = match select_model_id(&compress_req, &provider_config.model_configs) {
.unwrap_or_else(|| provider_config.default_model.clone()); Some(m) => m,
None => {
resolve_fallback_model(&provider_config.model_configs, &provider_config.default_model)?
}
};
// 防御:选中/兜底模型是图像模型时返 Err(走关键词摘要兜底,避免文本任务 404 刷屏)。 // 防御:选中/兜底模型是图像模型时返 Err(走关键词摘要兜底,避免文本任务 404 刷屏)。
if is_likely_image_model(&model, &provider_config.model_configs) { if is_likely_image_model(&model, &provider_config.model_configs) {
return Err(format!( return Err(format!(
@@ -150,6 +155,46 @@ pub(crate) async fn compress_via_llm(
}) })
} }
/// `select_model_id` 返 None(池空/无匹配)时的**兜底模型解析**(治 929 次生缺口)。
///
/// 原兜底直接 `provider_config.default_model.clone()`,不校验该模型的 `enabled` ——
/// 用户在设置里停用 default_model 后,压缩/标题仍照用被停用的模型(与 agentic 侧 override 路径
/// `resolve_model_with_override` 同源缺口:停用只挡路由,不挡兜底)。此处在 enabled 口径下解析:
///
/// - **default_model 在池中且启用** → 采用(正常兜底);
/// - **default_model 不在池中**(未拉取模型/池空/配置未同步)→ 无法校验,采用 default_model
/// (不因无法校验而阻断,保持旧行为);
/// - **default_model 在池中被显式停用** → 回落池中任一 enabled 模型(与 `select_model_id`
/// 的 enabled 过滤同口径);
/// - **池内无任何 enabled 模型** → Err(明确报错,不再静默沿用停用模型,让用户感知配置问题)。
///
/// 与 `select_model_id` 分工:后者按「需求匹配 + weight」选优;本函数只解决「兜底候选是否被停用」
/// 的校验与回落,不重复路由逻辑。抽为纯函数便于单测(零 IO)。
pub(crate) fn resolve_fallback_model(
pool: &[ModelConfig],
default_model: &str,
) -> Result<String, String> {
// 池中有该条目 → 以其 enabled 为准;无该条目 → 无法校验,不阻断(采用 default_model)。
let default_available = pool
.iter()
.find(|m| m.model_id == default_model)
.map(|m| m.enabled)
.unwrap_or(true);
if default_available {
return Ok(default_model.to_string());
}
// 回落:池中任一 enabled 模型(与 select_model_id 的 enabled 过滤同口径)。
pool.iter()
.find(|m| m.enabled)
.map(|m| m.model_id.clone())
.ok_or_else(|| {
format!(
"无可用模型:default_model「{}」已在模型配置中停用,且池内无任何启用模型(请在设置中启用模型)",
default_model
)
})
}
/// 阶段2(占位配对完整性):检测压缩段是否含未闭合占位 tool_result。 /// 阶段2(占位配对完整性):检测压缩段是否含未闭合占位 tool_result。
/// ///
/// 占位 tool_result(content 带 __PENDING__ 标记,审批挂起)若无对应 tool_call 头(头被裁/丢) /// 占位 tool_result(content 带 __PENDING__ 标记,审批挂起)若无对应 tool_call 头(头被裁/丢)
@@ -445,4 +490,47 @@ mod tests {
]; ];
assert!(!compress_segment_has_unclosed_placeholder(&msgs)); assert!(!compress_segment_has_unclosed_placeholder(&msgs));
} }
// ── 929 次生缺口:resolve_fallback_model(兜底模型须校验 enabled) ──
/// 构造池内模型(enabled 可控,其余用默认)。
fn mc(id: &str, enabled: bool) -> ModelConfig {
ModelConfig { enabled, ..ModelConfig::with_defaults(id) }
}
#[test]
fn fallback_default_enabled_in_pool_used() {
// default_model 在池中且启用 → 采用 default_model
let pool = vec![mc("deepseek-chat", true), mc("glm-4-flash", true)];
assert_eq!(
resolve_fallback_model(&pool, "deepseek-chat").unwrap(),
"deepseek-chat"
);
}
#[test]
fn fallback_default_absent_from_pool_used() {
// default_model 不在池中(未拉取/池空)→ 无法校验,不阻断,采用 default_model
let empty: Vec<ModelConfig> = vec![];
assert_eq!(resolve_fallback_model(&empty, "legacy-model").unwrap(), "legacy-model");
let pool = vec![mc("glm-4-flash", true)];
assert_eq!(resolve_fallback_model(&pool, "legacy-model").unwrap(), "legacy-model");
}
#[test]
fn fallback_default_disabled_falls_back_to_enabled_pool_model() {
// default_model 在池中被停用 → 回落池中任一 enabled 模型(不静默沿用停用模型)
let pool = vec![mc("stopped-model", false), mc("glm-4-flash", true)];
let got = resolve_fallback_model(&pool, "stopped-model").unwrap();
assert_eq!(got, "glm-4-flash", "停用的 default_model 应回落启用模型");
}
#[test]
fn fallback_default_disabled_and_no_enabled_errors() {
// default_model 停用 + 池内无任何启用模型 → 明确报错(而非静默用停用模型)
let pool = vec![mc("stopped-model", false), mc("also-stopped", false)];
let err = resolve_fallback_model(&pool, "stopped-model").unwrap_err();
assert!(err.contains("stopped-model"), "错误信息应含被停用的 default_model 名便于定位");
assert!(err.contains("停用"), "错误信息应说明停用原因");
}
} }
+119
View File
@@ -84,10 +84,69 @@ pub const DEFAULT_BUS_CAPACITY: usize = 256;
/// 事件值语义:emit 与 publish 收到**同一份事件值**(内部 clone),对齐原手抄 `ev.clone()` 两行式。 /// 事件值语义:emit 与 publish 收到**同一份事件值**(内部 clone),对齐原手抄 `ev.clone()` 两行式。
/// 消费 ev(调用方无需再 clone)。 /// 消费 ev(调用方无需再 clone)。
pub(crate) fn emit_ai_chat_event(app: &AppHandle, ev: AiChatEvent) { pub(crate) fn emit_ai_chat_event(app: &AppHandle, ev: AiChatEvent) {
// 子代理运行期事件改挂(见 CONV_ROUTE_OVERRIDE):作用域内命中来源会话的展示类事件
// 改挂目标会话再双写;未进作用域或未命中时保持原双写,常规路径零行为变化。
if let Ok((from, to)) = CONV_ROUTE_OVERRIDE.try_with(|r| r.clone()) {
if let Some(v) = route_conv_event(&ev, &from, &to) {
let _ = app.emit("ai-chat-event", v.clone());
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish(v);
return;
}
}
let _ = app.emit("ai-chat-event", ev.clone()); let _ = app.emit("ai-chat-event", ev.clone());
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev); let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
} }
// 子代理运行期事件路由覆写(2026-10-10)。
//
// spawn_agent 子循环运行期,子会话(from)的运行事件默认挂子会话 conv 推送,父会话视图收不
// 到子代理的实时进展(切回父会话只见 spawn 卡停在旧态,出现「已完成」幽灵态)。本 task-local
// 在子循环作用域内把 from 会话的**展示类**事件改挂父会话(to)conv 再推送,复用同一 emit 双写
// 出口(不开旁路);终态/状态机/审批/求助类事件不改写,避免污染父会话生成态与生命周期。
tokio::task_local! {
static CONV_ROUTE_OVERRIDE: (String, String);
}
/// 在「把 from 会话的展示类事件改挂 to 会话」的作用域内执行 future(子代理运行期包裹子循环
/// 的流式与工具调用;作用域外事件路由不变)。
pub(crate) async fn route_scope<F, R>(from: String, to: String, fut: F) -> R
where
F: std::future::Future<Output = R>,
{
CONV_ROUTE_OVERRIDE.scope((from, to), fut).await
}
/// 展示类事件白名单:仅这些随子代理运行改挂父会话视图(轮次/文本/心跳/重试/工具生命周期/
/// 命令输出)。负例(AiCompleted/AiError/AiAgentFinished/AiConvStateChanged/审批/求助/停靠等)
/// 承载父或子各自的会话生命周期,改写会污染父会话生成态,故不改写。
const ROUTABLE_EVENT_TYPES: &[&str] = &[
"AiAgentRound",
"AiTextDelta",
"AiHeartbeat",
"AiStreamRetry",
"AiToolCallStarted",
"AiToolCallCompleted",
"AiToolAutoApproved",
"AiCommandOutput",
];
/// 展示类且 conversation_id 命中 from → 返回改挂 to 后的 JSON 值;否则 None(调用方走原双写)。
///
/// 用序列化探针取 type/conversation_id,避免为 40+ 事件变体逐一手写 match(改一处漏一处的
/// 漂移源)。命中率极低(仅子代理运行期),开销可忽略。
fn route_conv_event(ev: &AiChatEvent, from: &str, to: &str) -> Option<serde_json::Value> {
let mut v = serde_json::to_value(ev).ok()?;
let ty = v.get("type").and_then(|t| t.as_str())?;
if !ROUTABLE_EVENT_TYPES.contains(&ty) {
return None;
}
if v.get("conversation_id").and_then(|c| c.as_str()) != Some(from) {
return None;
}
v["conversation_id"] = serde_json::Value::String(to.to_string());
Some(v)
}
// ============================================================ // ============================================================
// EventBus — 基于 tokio::sync::broadcast 的 pub-sub 总线(载荷 serde_json::Value) // EventBus — 基于 tokio::sync::broadcast 的 pub-sub 总线(载荷 serde_json::Value)
// //
@@ -337,4 +396,64 @@ mod tests {
assert_eq!(received, expected.clone(), "每个变体应原样透传(全透传不丢)"); assert_eq!(received, expected.clone(), "每个变体应原样透传(全透传不丢)");
} }
} }
// ============================================================
// route_conv_event — 子代理运行期事件改挂(白名单 × from 命中)
//
// 锁定三态:① 白名单内 + conversation_id==from → 改挂 to 返回 Some;
// ② 非白名单 → None(不改挂,避免污染父会话生成态);
// ③ conversation_id != from / None → None(不误改他会话事件)。
// ============================================================
/// 白名单内且 conversation_id 命中 from → 返回改挂 to 后的值(仅 conversation_id 变,type/其余字段原样)。
#[test]
fn route_conv_event_rewrites_from_to() {
let ev = AiChatEvent::AiTextDelta {
delta: "hi".into(),
conversation_id: Some("sub".into()),
};
let v = route_conv_event(&ev, "sub", "parent")
.expect("白名单内且命中 from 应返回 Some");
assert_eq!(v["type"], "AiTextDelta", "type 判别字段应保留");
assert_eq!(v["conversation_id"], "parent", "conversation_id 应改挂到 to");
assert_eq!(v["delta"], "hi", "其余字段应原样透传");
}
/// 非白名单事件(承载父/子会话生命周期)→ None,不改写。
#[test]
fn route_conv_event_rejects_non_whitelisted() {
// AiMaxRoundsReached 不在 ROUTABLE_EVENT_TYPES,改写会污染父会话状态机
let ev = AiChatEvent::AiMaxRoundsReached {
conversation_id: Some("sub".into()),
};
assert!(
route_conv_event(&ev, "sub", "parent").is_none(),
"非白名单事件不应改挂"
);
}
/// conversation_id 非 from(他会话来源)→ None,不动。
#[test]
fn route_conv_event_skips_unmatched_from() {
let ev = AiChatEvent::AiAgentRound {
round: 2,
conversation_id: Some("other".into()),
};
assert!(
route_conv_event(&ev, "sub", "parent").is_none(),
"conversation_id 非 from 不应改挂"
);
}
/// conversation_id 缺省(None)→ 不命中 → None。
#[test]
fn route_conv_event_skips_none_conv() {
let ev = AiChatEvent::AiHeartbeat {
conversation_id: None,
};
assert!(
route_conv_event(&ev, "sub", "parent").is_none(),
"conversation_id 缺省不应改挂"
);
}
} }
+34 -15
View File
@@ -9,30 +9,46 @@ use df_storage::models::AiProviderRecord;
use crate::commands::err_str; use crate::commands::err_str;
use crate::state::{AppState, SettingsNorm}; use crate::state::{AppState, SettingsNorm};
/// 获取当前活跃提供商配置 /// 获取当前活跃提供商配置。
///
/// **停用须生效(2026-10-10 用户报「模型配置停用无效」根因)**:原实现显式活跃 provider 直接返回、
/// 默认 provider 只按 `is_default` 挑,**全程不看 `enabled`** → 用户在设置里停用某 provider 后
/// 会话仍照用(停用形同虚设)。现两条路径都收敛到「启用集合」:显式活跃项被停用 → 改按启用集合解析
/// (留 warn 便于定位);无任何启用 provider → 明确报错而非静默使用被停用项。
pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRecord, String> { pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRecord, String> {
let session = state.ai_session.lock().await; let session = state.ai_session.lock().await;
if let Some(ref pid) = session.active_provider_id { if let Some(ref pid) = session.active_provider_id {
let provider = state if let Some(provider) = state.ai_providers.get_by_id(pid).await.map_err(err_str)? {
.ai_providers if provider.enabled {
.get_by_id(pid) return Ok(provider);
.await }
.map_err(err_str)? tracing::warn!(
.ok_or_else(|| format!("活跃提供商不存在: {}", pid))?; provider_id = %pid,
Ok(provider) provider_name = %provider.name,
"[ai] 活跃 provider 已被停用,改按启用集合解析默认 provider"
);
} else { } else {
// 查找默认提供商 tracing::warn!(provider_id = %pid, "[ai] 活跃 provider 不存在,改按启用集合解析");
}
}
drop(session); drop(session);
resolve_enabled_provider(state).await
}
/// 在**启用**的 provider 中解析:`is_default` 优先,否则取列表首个(与旧行为同序,仅多一道 enabled 过滤)。
async fn resolve_enabled_provider(state: &AppState) -> Result<AiProviderRecord, String> {
let providers = state let providers = state
.ai_providers .ai_providers
.list_all() .list_all()
.await .await
.map_err(err_str)?; .map_err(err_str)?;
let default = providers.iter().find(|p| p.is_default).cloned(); let enabled: Vec<AiProviderRecord> = providers.into_iter().filter(|p| p.enabled).collect();
default enabled
.or_else(|| providers.into_iter().next()) .iter()
.ok_or_else(|| "未配置 AI 提供商,请先在设置中添加".to_string()) .find(|p| p.is_default)
} .cloned()
.or_else(|| enabled.into_iter().next())
.ok_or_else(|| "未启用任何 AI 提供商,请在设置中启用一个(或新增)".to_string())
} }
/// 当前运行环境画像(注入 system prompt):日期 + OS + shell 执行姿势 + 可用解释器。 /// 当前运行环境画像(注入 system prompt):日期 + OS + shell 执行姿势 + 可用解释器。
@@ -45,7 +61,10 @@ pub(crate) async fn get_active_provider(state: &AppState) -> Result<AiProviderRe
/// - 可用解释器:Windows=python(无 python3);Linux·macOS=python3;node 均有 /// - 可用解释器:Windows=python(无 python3);Linux·macOS=python3;node 均有
/// ///
/// 跨设备:cfg! 分支,平台各自正确姿势。补充探测见 detect_environment 工具(失败自愈)。 /// 跨设备:cfg! 分支,平台各自正确姿势。补充探测见 detect_environment 工具(失败自愈)。
fn env_profile_line() -> String { ///
/// pub(crate):子代理 system prompt 同源注入(见 sub_agent::sub_agent_system_prompt),
/// 子代理同样需要日期(不能自行获取)与 shell 执行姿势。
pub(crate) fn env_profile_line() -> String {
let today = chrono::Local::now().format("%Y-%m-%d"); let today = chrono::Local::now().format("%Y-%m-%d");
format!("当前日期: {today} | {}", env_profile_stable_body()) format!("当前日期: {today} | {}", env_profile_stable_body())
} }
+14 -3
View File
@@ -18,6 +18,7 @@ use df_storage::models::AiProviderRecord;
use crate::state::LlmConcurrency; use crate::state::LlmConcurrency;
use super::compress::resolve_fallback_model;
use super::AiSession; use super::AiSession;
/// 对话完成后按需生成智能标题(仅 title 为空时触发一次,不覆盖用户改名) /// 对话完成后按需生成智能标题(仅 title 为空时触发一次,不覆盖用户改名)
@@ -120,15 +121,25 @@ pub(crate) async fn ensure_conversation_title(
return; return;
} }
}; };
// F-01 阶段5: 标题生成路由 — select_model_id None(池空/无匹配)→ 兜底 default_model。 // F-01 阶段5: 标题生成路由 — select_model_id None(池空/无匹配)→ 兜底 default_model,
// 但须校验其 enabled(停用则回落池内启用模型,见 compress::resolve_fallback_model);
// 池内无任何启用模型 → 跳过 LLM 保留 extract_title 兜底,不再静默用停用模型。
let title_req = TaskRequirements { let title_req = TaskRequirements {
modalities: vec![Modality::Text], modalities: vec![Modality::Text],
needs_tool_use: false, needs_tool_use: false,
estimated_context: 0, estimated_context: 0,
tier: None, tier: None,
}; };
let title_model = select_model_id(&title_req, &provider_config.model_configs) let title_model = match select_model_id(&title_req, &provider_config.model_configs) {
.unwrap_or_else(|| provider_config.default_model.clone()); Some(m) => m,
None => match resolve_fallback_model(&provider_config.model_configs, &provider_config.default_model) {
Ok(m) => m,
Err(e) => {
tracing::warn!("标题 LLM 跳过(无可用模型): {}", e);
return;
}
},
};
// 防御:选中/兜底模型是图像模型时跳过 LLM(避免文本任务 404 刷屏,治 default_model 配错为图像模型)。 // 防御:选中/兜底模型是图像模型时跳过 LLM(避免文本任务 404 刷屏,治 default_model 配错为图像模型)。
// 跳过则保留上方 extract_title 兜底,不锁定差标题。 // 跳过则保留上方 extract_title 兜底,不锁定差标题。
if is_likely_image_model(&title_model, &provider_config.model_configs) { if is_likely_image_model(&title_model, &provider_config.model_configs) {
+43 -2
View File
@@ -463,6 +463,11 @@ pub async fn get_module_git_status(
.ok_or_else(|| format!("工程 {module_id} 不存在"))?; .ok_or_else(|| format!("工程 {module_id} 不存在"))?;
let path = std::path::Path::new(&module.path); let path = std::path::Path::new(&module.path);
// F22: 工程目录本身不存在时,不能与非 Git 仓库混为一谈(前端 GitChanges 会误显"非 Git 仓库")。
// 先判目录,返回明确错误 → 前端走 loadError 分支显示真实原因。
if !path.is_dir() {
return Err(format!("工程目录不存在: {}", module.path));
}
// 无 .git → 返回空状态 // 无 .git → 返回空状态
if !path.join(".git").exists() { if !path.join(".git").exists() {
return Ok(serde_json::to_value(empty_status()).map_err(err_str)?); return Ok(serde_json::to_value(empty_status()).map_err(err_str)?);
@@ -1057,7 +1062,7 @@ pub async fn get_module_file_diff(
if !root.join(".git").exists() { if !root.join(".git").exists() {
return Ok(serde_json::json!({"path": file_path, "diff": ""})); return Ok(serde_json::json!({"path": file_path, "diff": ""}));
} }
// 跑 git diff <path>(工作树 vs 索引的 unstaged 变更+staged 变更) // 跑 git diff <path>(工作树 vs 索引 = 仅 unstaged 未暂存变更;staged 与 untracked 不在输出内,见 F4)
// spawn_blocking + run_git_cmd(10s 超时,防 git 卡死阻塞 runtime)。 // spawn_blocking + run_git_cmd(10s 超时,防 git 卡死阻塞 runtime)。
let cmd_dir = root.clone(); let cmd_dir = root.clone();
let path_arg = file_path.replace('\\', "/"); let path_arg = file_path.replace('\\', "/");
@@ -1187,6 +1192,21 @@ pub async fn scan_project_modules(
Ok(new_count) Ok(new_count)
} }
/// F24:把 git status 输出的「相对仓库根」路径折算为「相对 module.path」。
///
/// `git status --porcelain` 的路径恒相对**仓库根**(与 `status.relativePaths` 无关,该配置对
/// porcelain 不生效)。当 module.path 是仓库子目录(模块路径≠仓库根)时,键与文件树条目
/// (相对 module.path)对不上 → git 徽标全失效。prefix = `git rev-parse --show-prefix`
/// (当前目录相对仓库根,如 `crates/`;仓库根为空串)。前缀非空时不以前缀开头的路径属仓库
/// 其它位置,不归本模块 → None(丢弃);空前缀原样返回。
fn scope_git_path(path: &str, prefix: &str) -> Option<String> {
if prefix.is_empty() {
Some(path.to_string())
} else {
path.strip_prefix(prefix).map(|s| s.to_string())
}
}
/// 采集工程目录 git status --porcelain 的 {posix_rel_path: status} 映射。 /// 采集工程目录 git status --porcelain 的 {posix_rel_path: status} 映射。
/// 非 git 仓库/命令失败 → 空映射(不阻断文件树展示,仅 git_status 字段全 None)。 /// 非 git 仓库/命令失败 → 空映射(不阻断文件树展示,仅 git_status 字段全 None)。
fn collect_git_status_map(dir: &str) -> HashMap<String, String> { fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
@@ -1206,6 +1226,11 @@ fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
) else { ) else {
return map; return map;
}; };
// F24: porcelain 路径相对仓库根,若 module.path 是子目录需剥除其相对仓库根的前缀,
// 使键统一相对 module.path(与文件树条目对齐)。取失败(退化)→ 空前缀,保持原行为。
let prefix = run_git_cmd(path, &["rev-parse", "--show-prefix"], timeout)
.map(|s| s.trim().to_string())
.unwrap_or_default();
for line in out.lines() { for line in out.lines() {
if line.len() < 4 { if line.len() < 4 {
continue; continue;
@@ -1226,7 +1251,10 @@ fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
.split_once(" -> ") .split_once(" -> ")
.map(|(_, new)| new.to_string()) .map(|(_, new)| new.to_string())
.unwrap_or(cleaned); .unwrap_or(cleaned);
map.insert(final_path, status); // F24: 折算为相对 module.path;仓库其它位置的改动不归本模块,前缀非空时丢弃。
if let Some(scoped) = scope_git_path(&final_path, &prefix) {
map.insert(scoped, status);
}
} }
map map
} }
@@ -1606,5 +1634,18 @@ mod tests {
cache.remove("repoA"); cache.remove("repoA");
assert!(cache.get("repoA").is_none()); assert!(cache.get("repoA").is_none());
} }
/// F24:git 路径折算 — 仓库根(空前缀)原样保留;子目录模块剥前缀;仓库其它位置丢弃。
#[test]
fn test_scope_git_path() {
// 仓库根:空前缀 → 原样返回(行为与前一致)
assert_eq!(scope_git_path("src/main.rs", ""), Some("src/main.rs".to_string()));
// 子目录模块 sub/:剥除 "sub/" 前缀 → 相对 module.path
assert_eq!(scope_git_path("sub/src/a.rs", "sub/"), Some("src/a.rs".to_string()));
// 仓库其它位置(不在 sub/ 下)→ 丢弃
assert_eq!(scope_git_path("other/b.rs", "sub/"), None);
// 前缀边界:"subx/" 与 "sub/" 同名前缀不误剥(前缀必带尾斜杠)
assert_eq!(scope_git_path("subx/c.rs", "sub/"), None);
}
} }
+16 -5
View File
@@ -518,14 +518,25 @@ pub async fn relocate_project_path(
id: String, id: String,
new_path: String, new_path: String,
) -> Result<ProjectRecord, String> { ) -> Result<ProjectRecord, String> {
if !Path::new(&new_path).is_dir() { // BE-CMD-12:relocate 与 create_with_binding / update_project(path) 同款校验——
return Err(format!("目录不存在: {}", new_path)); // `..` 段拦截(防穿越)+ normalize_path 规范化后落库(防路径写法差异致重复绑定/白名单授权失效)。
let trimmed = new_path.trim();
if trimmed.is_empty() {
return Err("路径不能为空".to_string());
} }
if let Some(conflict) = find_binding_conflict(&state, &new_path, Some(&id)).await? { if trimmed.split(['\\', '/']).any(|seg| seg == "..") {
return Err(format!("路径不得包含 '..' 段: {}", trimmed));
}
if !Path::new(trimmed).is_dir() {
return Err(format!("目录不存在: {}", trimmed));
}
if let Some(conflict) = find_binding_conflict(&state, trimmed, Some(&id)).await? {
return Err(format!("目录已被项目「{}」绑定", conflict.name)); return Err(format!("目录已被项目「{}」绑定", conflict.name));
} }
// 规范化路径后探测与落库(对齐 create_with_binding/update_project,存入规范化值)
let normalized = normalize_path(trimmed);
// 重探测技术栈(spawn_blocking 防 IO 阻塞 tokio runtime) // 重探测技术栈(spawn_blocking 防 IO 阻塞 tokio runtime)
let root = std::path::PathBuf::from(&new_path); let root = std::path::PathBuf::from(&normalized);
let stack = tokio::task::spawn_blocking(move || detect_stack(&root)) let stack = tokio::task::spawn_blocking(move || detect_stack(&root))
.await .await
.map_err(err_str)? .map_err(err_str)?
@@ -533,7 +544,7 @@ pub async fn relocate_project_path(
let stack_json = serde_json::to_string(&stack).map_err(err_str)?; let stack_json = serde_json::to_string(&stack).map_err(err_str)?;
state state
.projects .projects
.update_field(&id, "path", &new_path) .update_field(&id, "path", &normalized)
.await .await
.map_err(err_str)?; .map_err(err_str)?;
// 重定位目录后立即 reload 白名单(新路径即时授权,防 AI 误弹窗) // 重定位目录后立即 reload 白名单(新路径即时授权,防 AI 误弹窗)
+15 -1
View File
@@ -387,13 +387,21 @@ pub async fn update_task(
// review_rounds 累加,唯一 status 写入路径)。即此 is_valid 校验的「通过」分支永不会触达 // review_rounds 累加,唯一 status 写入路径)。即此 is_valid 校验的「通过」分支永不会触达
// update_field 的 status 写入(白名单会先拒);它只为非法值兜底 UX,不承担合法值写入职责。 // update_field 的 status 写入(白名单会先拒);它只为非法值兜底 UX,不承担合法值写入职责。
// priority 值校验同理补在下方:拦截 "abc" / 999 等脏数据静默落库。 // priority 值校验同理补在下方:拦截 "abc" / 999 等脏数据静默落库。
if field == "status" && !TaskStatus::is_valid(&value) { if field == "status" {
if !TaskStatus::is_valid(&value) {
return Err(format!( return Err(format!(
"非法 status 值 {:?},合法值: {:?}", "非法 status 值 {:?},合法值: {:?}",
value, value,
TaskStatus::valid_values() TaskStatus::valid_values()
)); ));
} }
// BE-CMD-20:合法 status 值亦不可经本 IPC 写入(status 已移出 tasks 白名单、收口到状态机),
// 给出可操作指引,避免落到通用 update_field 的白名单冷错误(「表 tasks 不允许的字段名」)。
return Err(
"status 不可经 update_task 直接修改,请走 advance_task(状态机,含转换校验与 review_rounds 累加)"
.to_string(),
);
}
// priority 值域 0..=3(0=critical, 1=high, 2=medium, 3=low),与前端 <select> 及 // priority 值域 0..=3(0=critical, 1=high, 2=medium, 3=low),与前端 <select> 及
// constants/project.ts 的 PRIORITY_LABELS 一致。拦截 "abc" / 999 等脏数据。 // constants/project.ts 的 PRIORITY_LABELS 一致。拦截 "abc" / 999 等脏数据。
if field == "priority" { if field == "priority" {
@@ -407,6 +415,12 @@ pub async fn update_task(
} }
} }
} }
// BE-CMD-9:content_json 是 JSON 字段,补合法性校验(对齐 create_task 与 update_idea 的
// related_ids/scores),防脏 JSON 静默落库;空串视为清除,放行。
if field == "content_json" && !value.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(&value)
.map_err(|e| format!("content_json 不是合法 JSON: {e}"))?;
}
// project_id 跨表存在性校验(收尾)。 // project_id 跨表存在性校验(收尾)。
// crud.rs tasks 白名单保留 project_id(支持跨项目移动),跨表约束由本命令层兜底。 // crud.rs tasks 白名单保留 project_id(支持跨项目移动),跨表约束由本命令层兜底。
// 拦截移动到不存在的项目(手输脏 id / 已物理删除的项目),避免 tasks.project_id 悬空。 // 拦截移动到不存在的项目(手输脏 id / 已物理删除的项目),避免 tasks.project_id 悬空。
+127 -90
View File
@@ -630,11 +630,10 @@ async fn handle_message(
let (tx, _rx) = tokio::sync::oneshot::channel::<String>(); let (tx, _rx) = tokio::sync::oneshot::channel::<String>();
state.register_agent_completion(conv_id.clone(), tx); state.register_agent_completion(conv_id.clone(), tx);
// 流式预览:订阅既有 ai_event_bus 拿本 conv 的 AiTextDelta 增量(emit_ai_chat_event // 过程可见性(13a):预览任务订阅既有 ai_event_bus 拿本 conv 的增量(delta/轮次/工具/
// 双写总线已接入,渠道侧纯消费,零改动接线)。spawn 前订阅防早期增量丢失; // 进度)节流刷新占位卡;受理卡与终版由完成订阅任务经帧通道投递。本任务为占位卡唯一
// 终版文本经 deliver_tx 也交给本任务单点投递,占位卡片自 spawn 起只被这一个任务写, // 写者——中间态与终版同任务串行,无迟到中间态覆盖终版的竞态。spawn 前订阅防早期增量丢失
// 中间态与终版写卡天然串行,无迟到中间态覆盖终版的竞态 let (frame_tx, frame_rx) = tokio::sync::mpsc::channel::<CardFrame>(8);
let (deliver_tx, deliver_rx) = tokio::sync::oneshot::channel::<String>();
let delta_rx = state.ai_event_bus.subscribe(); let delta_rx = state.ai_event_bus.subscribe();
let stream_task = tauri::async_runtime::spawn(run_stream_refresher( let stream_task = tauri::async_runtime::spawn(run_stream_refresher(
ctx.clone(), ctx.clone(),
@@ -643,7 +642,7 @@ async fn handle_message(
message_id.clone(), message_id.clone(),
chat_id.clone(), chat_id.clone(),
delta_rx, delta_rx,
deliver_rx, frame_rx,
)); ));
let task_brief = truncate(&text, 60); // move 前先取展示摘要(spawn 会 move text) let task_brief = truncate(&text, 60); // move 前先取展示摘要(spawn 会 move text)
@@ -669,9 +668,9 @@ async fn handle_message(
Err(e) => { Err(e) => {
// 启动失败:通知桥条目清掉防死条目(编排节点同款) // 启动失败:通知桥条目清掉防死条目(编排节点同款)
state.notify_agent_completion(&conv_id, String::new()); state.notify_agent_completion(&conv_id, String::new());
// 关终版通道让流式任务退出(此路无任何增量产出,卡片未被流式写过), // 关帧通道让预览任务退出(此路无任何帧产出,卡片未被写过),
// 待其退出后再走失败投递,保卡片写入单点次序 // 待其退出后再走失败投递,保卡片写入单点次序
drop(deliver_tx); drop(frame_tx);
let _ = stream_task.await; let _ = stream_task.await;
feishu_log(&format!("spawn 失败 conv={conv_id}: {e}")); feishu_log(&format!("spawn 失败 conv={conv_id}: {e}"));
// 18 渠道错误话术透传:并发护栏拒绝(生成中)≠真失败——固定「启动失败」话术 // 18 渠道错误话术透传:并发护栏拒绝(生成中)≠真失败——固定「启动失败」话术
@@ -701,11 +700,10 @@ async fn handle_message(
// 消息处理 30 分钟,后续消息(含注入旁路)即时响应。终版投递改由 completion_watcher // 消息处理 30 分钟,后续消息(含注入旁路)即时响应。终版投递改由 completion_watcher
// 消费 ai_event_bus 的 AiAgentFinished 广播(引擎层 deliver_completion_report_if_waited // 消费 ai_event_bus 的 AiAgentFinished 广播(引擎层 deliver_completion_report_if_waited
// 统一发出,单一事实源),原地 edit_card 占位卡为结果。 // 统一发出,单一事实源),原地 edit_card 占位卡为结果。
// 卡片所有权移交(走查🔴#1 修正):占位卡自此只被 watcher 任务写——受理卡与终版 // 卡片所有权(13a 修订):占位卡唯一写者是预览任务(run_stream_refresher),受理卡与
// 都在 watcher 单任务内按序落卡(受理卡先写,快任务兜底命中时跳过受理卡直投终版), // 终版都由本完成订阅任务经帧通道交给它按序落卡;预览任务内部串行,消除「受理卡与终版
// 彻底消除「受理卡与终版两个 HTTP 请求网络乱序,终版被受理卡覆盖丢失」的竞态窗口。 // 两个 HTTP 请求网络乱序,终版被受理卡覆盖丢失」的竞态窗口。13a 前独立预览任务在受理
drop(deliver_tx); // 即被关停(drop 帧通道),心跳/阶段进展形同虚设;此处交回帧通道驱动使其真正落到卡片。
let _ = stream_task.await; // 等流式任务退出(快,只走通道关闭分支)
let ack = format!("🚀 已受理,后台执行中\n任务:{task_brief}"); let ack = format!("🚀 已受理,后台执行中\n任务:{task_brief}");
feishu_log(&format!("受理返回 chat={chat_id} conv={conv_id}")); feishu_log(&format!("受理返回 chat={chat_id} conv={conv_id}"));
@@ -723,12 +721,11 @@ async fn handle_message(
run_completion_watcher( run_completion_watcher(
ctx, ctx,
conv_id, conv_id,
placeholder_id,
message_id,
chat_id,
ack, ack,
inflight_guard, inflight_guard,
cancel_rx, cancel_rx,
frame_tx,
stream_task,
) )
.await; .await;
// watcher 任意路径退出都摘除取消句柄,防陈旧条目驻留(后续 /cancel 空发无害) // watcher 任意路径退出都摘除取消句柄,防陈旧条目驻留(后续 /cancel 空发无害)
@@ -741,9 +738,16 @@ async fn handle_message(
/// 判定秒级任务已完成。8s 覆盖秒级任务;长任务仅推迟主循环进入,不影响终版到达时刻 /// 判定秒级任务已完成。8s 覆盖秒级任务;长任务仅推迟主循环进入,不影响终版到达时刻
const FAST_TASK_GRACE: std::time::Duration = std::time::Duration::from_secs(8); const FAST_TASK_GRACE: std::time::Duration = std::time::Duration::from_secs(8);
/// 完成事件订阅任务(#14 两段式):占位卡唯一写者。先投受理卡,再消费 ai_event_bus 的 /// 卡片帧:占位卡唯一写者(预览任务)与裁决者(完成订阅任务)之间的投递契约。
/// AiAgentFinished(本 conv)原地 edit_card 为终版。引擎层 deliver_completion_report_if_waited /// terminal=true 的帧(终版/取消/失败)写入后预览任务收工退出,保证卡片最后写入即终版。
/// 是广播单一发出点,watcher 纯消费无状态。 struct CardFrame {
text: String,
terminal: bool,
}
/// 完成事件订阅任务(#14 两段式):只做裁决——消费 ai_event_bus 的 AiAgentFinished(本 conv)
/// 决定终版文本,经帧通道交给预览任务原地落卡(占位卡唯一写者是预览任务,受理卡/终版/阶段
/// 提示全部经帧通道按序落卡)。引擎层 deliver_completion_report_if_waited 是广播单一发出点。
/// ///
/// 超时语义:35min(> loop 30min 上限)首超时回查边界内产出,无果投降级提示但不退出 /// 超时语义:35min(> loop 30min 上限)首超时回查边界内产出,无果投降级提示但不退出
/// (任务后台不丢,晚到完成事件仍送达);二次超时真弃守(#23:两轮均无事件且无边界内 /// (任务后台不丢,晚到完成事件仍送达);二次超时真弃守(#23:两轮均无事件且无边界内
@@ -751,14 +755,15 @@ const FAST_TASK_GRACE: std::time::Duration = std::time::Duration::from_secs(8);
async fn run_completion_watcher( async fn run_completion_watcher(
ctx: Arc<Ctx>, ctx: Arc<Ctx>,
conv_id: String, conv_id: String,
placeholder_id: Option<String>,
message_id: String,
chat_id: String,
ack: String, ack: String,
// #20:计数生命周期跟任务走,guard 持有至 watcher 退出 // #20:计数生命周期跟任务走,guard 持有至 watcher 退出
_inflight: InflightGuard, _inflight: InflightGuard,
// 24 取消收口:/cancel 旁路唤醒(引擎停止路径只 emit AiCompleted,本任务消费不到) // 24 取消收口:/cancel 旁路唤醒(引擎停止路径只 emit AiCompleted,本任务消费不到)
mut cancel_rx: tokio::sync::oneshot::Receiver<()>, mut cancel_rx: tokio::sync::oneshot::Receiver<()>,
// 13a:占位卡写权限交预览任务,本任务所有落卡经此投递(terminal 标记终版)
frame_tx: tokio::sync::mpsc::Sender<CardFrame>,
// 预览任务句柄:投终版后 await 其退出,保证落卡完成后才释放 inflight
stream_task: tauri::async_runtime::JoinHandle<()>,
) { ) {
let state = ctx.app.state::<crate::state::AppState>(); let state = ctx.app.state::<crate::state::AppState>();
let mut rx = state.ai_event_bus.subscribe(); let mut rx = state.ai_event_bus.subscribe();
@@ -768,8 +773,8 @@ async fn run_completion_watcher(
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64) .map(|d| d.as_millis() as i64)
.unwrap_or(0); .unwrap_or(0);
// 落受理卡(占位缺失回退新发,deliver_result 内部链路) // 落受理卡(经预览任务原地更新占位卡,未收到终版前预览任务持续刷新过程进展)
deliver_result(&ctx.client, &placeholder_id.clone(), &message_id.clone(), &chat_id.clone(), &ack).await; let _ = frame_tx.send(CardFrame { text: ack, terminal: false }).await;
// #27 快任务兜底:受理卡发出后开宽限窗再判定,事件驱动替代恒假的时间比较。 // #27 快任务兜底:受理卡发出后开宽限窗再判定,事件驱动替代恒假的时间比较。
// 秒级任务的完成事件可能先于本订阅已广播完(错过),窗结束时 conv 已归非活跃且 // 秒级任务的完成事件可能先于本订阅已广播完(错过),窗结束时 conv 已归非活跃且
// 边界内有产出,即判快任务已完成,直投终版 // 边界内有产出,即判快任务已完成,直投终版
@@ -778,7 +783,8 @@ async fn run_completion_watcher(
if let Some(t) = last_assistant_reply(&state.db, &conv_id, Some(accepted_ms)).await { if let Some(t) = last_assistant_reply(&state.db, &conv_id, Some(accepted_ms)).await {
if !t.trim().is_empty() { if !t.trim().is_empty() {
feishu_log(&format!("watcher 快任务兜底 conv={conv_id} len={}", t.len())); feishu_log(&format!("watcher 快任务兜底 conv={conv_id} len={}", t.len()));
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &t).await; let _ = frame_tx.send(CardFrame { text: t, terminal: true }).await;
let _ = stream_task.await;
return; return;
} }
} }
@@ -787,19 +793,11 @@ async fn run_completion_watcher(
let timeout = std::time::Duration::from_secs(2100); let timeout = std::time::Duration::from_secs(2100);
let mut timeouts = 0usize; let mut timeouts = 0usize;
loop { loop {
let text = tokio::select! { let frame = tokio::select! {
// 24 取消收口:/cancel 旁路唤醒,投「已停止」终版后退出,不再等引擎事件 // 24 取消收口:/cancel 旁路唤醒,投「已停止」终版后退出,不再等引擎事件
_ = &mut cancel_rx => { _ = &mut cancel_rx => {
feishu_log(&format!("watcher 收到取消指令 conv={conv_id}")); feishu_log(&format!("watcher 收到取消指令 conv={conv_id}"));
deliver_result( CardFrame { text: "🛑 任务已停止。可发 /status 查看会话状态,或直接发新任务。".to_string(), terminal: true }
&ctx.client,
&placeholder_id,
&message_id,
&chat_id,
"🛑 任务已停止。可发 /status 查看会话状态,或直接发新任务。",
)
.await;
return;
} }
item = rx.recv() => match item { item = rx.recv() => match item {
Ok(v) => { Ok(v) => {
@@ -813,11 +811,17 @@ async fn run_completion_watcher(
Some("AiError") => { Some("AiError") => {
let err = v.get("error").and_then(|s| s.as_str()).unwrap_or("未知错误"); let err = v.get("error").and_then(|s| s.as_str()).unwrap_or("未知错误");
feishu_log(&format!("watcher 收到 AiError conv={conv_id}")); feishu_log(&format!("watcher 收到 AiError conv={conv_id}"));
format!("❌ 任务执行失败:{},可稍后重试,或发 /status 查看会话状态。", truncate(err, 300)) CardFrame {
text: format!("❌ 任务执行失败:{},可稍后重试,或发 /status 查看会话状态。", truncate(err, 300)),
terminal: true,
}
} }
Some("AiAgentFinished") => { Some("AiAgentFinished") => {
// 广播语义:agent_id==conv_id 时 summary 即本会话最终报告(引擎层构造) // 广播语义:agent_id==conv_id 时 summary 即本会话最终报告(引擎层构造)
v.get("summary").and_then(|s| s.as_str()).unwrap_or("").to_string() CardFrame {
text: v.get("summary").and_then(|s| s.as_str()).unwrap_or("").to_string(),
terminal: true,
}
} }
_ => continue, _ => continue,
} }
@@ -829,25 +833,30 @@ async fn run_completion_watcher(
timeouts += 1; timeouts += 1;
// 二次超时弃守:两轮 35min 均无事件且无边界内产出,认任务失联,停止重刷 // 二次超时弃守:两轮 35min 均无事件且无边界内产出,认任务失联,停止重刷
if timeouts >= 2 { if timeouts >= 2 {
let give_up = "⏰ 任务超 70 分钟无任何结果(已停止等待;引擎仍在后台运行则结果稍后可在桌面端查看;可发 /status 查询)。".to_string();
feishu_log(&format!("watcher 二次超时弃守 conv={conv_id}")); feishu_log(&format!("watcher 二次超时弃守 conv={conv_id}"));
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &give_up).await; CardFrame {
return; text: "⏰ 任务超 70 分钟无任何结果(已停止等待;引擎仍在后台运行则结果稍后可在桌面端查看;可发 /status 查询)。".to_string(),
terminal: true,
} }
} else {
// 首超时:DB 回查兜底(任务可能刚完成事件已发完);回查有果照投终版, // 首超时:DB 回查兜底(任务可能刚完成事件已发完);回查有果照投终版,
// 无果投降级提示并继续等待(晚到事件仍送达) // 无果投降级提示并继续等待(晚到事件仍送达)
match last_assistant_reply(&state.db, &conv_id, Some(accepted_ms)).await { match last_assistant_reply(&state.db, &conv_id, Some(accepted_ms)).await {
Some(t) if !t.trim().is_empty() => t, Some(t) if !t.trim().is_empty() => CardFrame { text: t, terminal: true },
_ => { _ => {
let notice = "⏰ 任务执行超 35 分钟仍未返回(后台仍在运行,完成后本卡片自动更新;或本会话 /status 查询)。".to_string(); let _ = frame_tx.send(CardFrame {
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &notice).await; text: "⏰ 任务执行超 35 分钟仍未返回(后台仍在运行,完成后本卡片自动更新;或本会话 /status 查询)。".to_string(),
terminal: false,
}).await;
continue; continue;
} }
} }
} }
}
}; };
feishu_log(&format!("watcher 终版 conv={conv_id} len={}", text.len())); feishu_log(&format!("watcher 终版 conv={conv_id} len={}", frame.text.len()));
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &text).await; let _ = frame_tx.send(frame).await;
let _ = stream_task.await; // 等预览任务落卡终版并退出,再释放 inflight
return; return;
} }
} }
@@ -1199,18 +1208,33 @@ async fn read_whitelist(ctx: &Ctx) -> Vec<String> {
const STREAM_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3); const STREAM_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3);
/// 流式预览上限字符数:对齐终版 clip_for_card 口径;超限从头截断保尾部 /// 流式预览上限字符数:对齐终版 clip_for_card 口径;超限从头截断保尾部
const STREAM_PREVIEW_MAX_CHARS: usize = 8000; const STREAM_PREVIEW_MAX_CHARS: usize = 8000;
/// 13a-2 静默期心跳阈值:超此秒数无本 conv 事件(delta/轮次/工具),tick 强制刷心跳行。 /// 13a-2 静默期心跳阈值:超此秒数无本 conv 内容进展(delta/轮次/工具/进度),tick 强制刷心跳行。
/// 60s 取「长工具常见执行时长」与「卡片冻结感阈值」之间——再短会与常规刷新混叠, /// 60s 取「长工具常见执行时长」与「卡片冻结感阈值」之间——再短会与常规刷新混叠,
/// 再长用户已开始疑心卡死。心跳行此后每静默分钟刷一次(桶去重),事件恢复即自然停 /// 再长用户已开始疑心卡死。心跳行此后每静默分钟刷一次(桶去重),内容恢复即自然停。
/// 注意:AiHeartbeat 等引擎保活事件不计入活动钟(非内容进展),否则心跳节拍会把静默永远压在阈值内
const HEARTBEAT_SILENT_THRESHOLD: u64 = 60; const HEARTBEAT_SILENT_THRESHOLD: u64 = 60;
/// 流式预览任务:消费 ai_event_bus 的 AiTextDelta,节流把累计文本 edit_card 进占位卡片; /// 13a-2 心跳刷判定(纯函数,单测锁定):内容静默达阈值且进入新的静默分钟桶时返回该桶号
/// 终版文本由 deliver_tx 单点传入,复用 deliver_result 投递后退出。 /// (应刷),否则 None(不刷)。桶去重保证每个静默分钟只刷一次,内容恢复(静默回落)即停。
fn heartbeat_due(silent_secs: u64, painted_bucket: Option<u64>, threshold: u64) -> Option<u64> {
if silent_secs < threshold {
return None;
}
let bucket = silent_secs / 60;
if Some(bucket) == painted_bucket {
None
} else {
Some(bucket)
}
}
/// 流式预览任务:订阅 ai_event_bus 的增量(delta/轮次/工具/进度)节流把累计内容 edit_card
/// 进占位卡;受理卡/阶段提示/终版经帧通道(frame_rx)传入,本任务是占位卡唯一写者。
/// ///
/// 竞态设计:占位卡片在本任务存活期只有本任务写,中间态 tick 与终版投递同任务串行, /// 竞态设计:占位卡在本任务存活期只有本任务写,中间态 tick 与帧投递同任务串行,上一次
/// 上一次 edit_card 的 HTTP 响应返回后才可能发下一次,飞书服务端所见次序即代码次序, /// edit_card 的 HTTP 响应返回后才可能发下一次,飞书服务端所见次序即代码次序,终版不会
/// 终版不会被迟到的中间态覆盖,故无需 AbortHandle。中间态刷失败不新发卡片回退 /// 被迟到的中间态覆盖,故无需 AbortHandle。中间态刷失败不新发卡片回退(不值得为预览刷屏),
/// (不值得为预览刷屏),新发回退链仍归终版 deliver_result 独有,行为不受本任务影响。 /// 新发回退链归帧投递(deliver_result)独有。
async fn run_stream_refresher( async fn run_stream_refresher(
ctx: Arc<Ctx>, ctx: Arc<Ctx>,
conv_id: String, conv_id: String,
@@ -1218,7 +1242,7 @@ async fn run_stream_refresher(
message_id: String, message_id: String,
chat_id: String, chat_id: String,
mut delta_rx: tokio::sync::broadcast::Receiver<serde_json::Value>, mut delta_rx: tokio::sync::broadcast::Receiver<serde_json::Value>,
mut deliver_rx: tokio::sync::oneshot::Receiver<String>, mut frame_rx: tokio::sync::mpsc::Receiver<CardFrame>,
) { ) {
let mut acc = String::new(); let mut acc = String::new();
// 累计字符数 O(1) 维护,与上次已刷数比对判"有无新增量",避免无变化空刷烧配额 // 累计字符数 O(1) 维护,与上次已刷数比对判"有无新增量",避免无变化空刷烧配额
@@ -1239,22 +1263,28 @@ async fn run_stream_refresher(
let mut progress_tokens: Option<u64> = None; let mut progress_tokens: Option<u64> = None;
// 13a-1 刷新可观测:流式刷失败连败计数(成功归零;==阈值告警一次,继续累计防重复告警) // 13a-1 刷新可观测:流式刷失败连败计数(成功归零;==阈值告警一次,继续累计防重复告警)
let mut refresh_failures: u32 = 0; let mut refresh_failures: u32 = 0;
// 13a-2 静默期心跳:最后活动时刻(本 conv 任意事件都算活着);静默超阈值强制刷心跳行, // 13a-2 静默期心跳:最后内容进展时刻(delta/轮次/工具/进度;不含 AiHeartbeat);静默超阈值
// 防长工具/LLM 长往返期间卡片冻结在旧帧,用户误判卡死(todo.md 13a-2) // 强制刷心跳行,防长工具/LLM 长往返期间卡片冻结在旧帧,用户误判卡死(todo.md 13a-2)
let mut last_activity = tokio::time::Instant::now(); let mut last_activity = tokio::time::Instant::now();
let mut painted_hb_bucket: Option<u64> = None; let mut painted_hb_bucket: Option<u64> = None;
// 受理卡(首帧)到达前不写卡:防心跳/预览帧先于受理卡落卡,次序错乱
let mut started = false;
let mut ticker = tokio::time::interval(STREAM_FLUSH_INTERVAL); let mut ticker = tokio::time::interval(STREAM_FLUSH_INTERVAL);
// 刷新落后只保最新帧不补帧:预览语义是最新输出才有价值 // 刷新落后只保最新帧不补帧:预览语义是最新输出才有价值
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop { loop {
tokio::select! { tokio::select! {
// 终版单点收口:投递后任务退出,卡片所有权移交终版内容 // 帧通道:受理卡/阶段提示/终版。走 deliver_result(净化+裁剪+新发兜底),与预览同任务串行
final_text = &mut deliver_rx => { frame = frame_rx.recv() => {
if let Ok(text) = final_text { let Some(f) = frame else {
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &text).await; // 发送端丢弃(watcher 退出/受理失败):无更多帧,收工
}
// 发送端被丢弃(spawn 失败路径)时不投递,由调用方兜底
return; return;
};
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &f.text).await;
started = true;
if f.terminal {
return;
}
} }
item = delta_rx.recv() => match item { item = delta_rx.recv() => match item {
Ok(v) => { Ok(v) => {
@@ -1267,11 +1297,10 @@ async fn run_stream_refresher(
if let Some(delta) = v.get("delta").and_then(|d| d.as_str()) { if let Some(delta) = v.get("delta").and_then(|d| d.as_str()) {
acc.push_str(delta); acc.push_str(delta);
acc_chars += delta.chars().count(); acc_chars += delta.chars().count();
// 首帧提速(2026-10-08):首个 delta 到达即立即刷占位卡, // 首帧提速(2026-10-08):首个 delta 到达且受理卡已落地即立即刷占位卡,
// 不等 3s ticker——短响应(简单问答/翻译)原需等满一个节流周期 // 不等 3s ticker——短响应(简单问答/翻译)原需等满一个节流周期才见首字,
// 才见首字,感知停顿明显。仅首帧走即时刷(acc_chars 首次>painted), // 感知停顿明显。仅首帧走即时刷,后续仍按 3s 节流守飞书 QPS 配额
// 后续仍按 3s 节流守飞书 QPS 配额 if started && painted_chars == 0 && acc_chars > 0 {
if painted_chars == 0 && acc_chars > 0 {
if let Some(pid) = placeholder_id.as_deref().filter(|id| !id.is_empty()) { if let Some(pid) = placeholder_id.as_deref().filter(|id| !id.is_empty()) {
let progress_snapshot = progress_round.map(|pr| (pr, progress_max, progress_tokens)); let progress_snapshot = progress_round.map(|pr| (pr, progress_max, progress_tokens));
if update_card(&ctx.client, pid, &stream_preview(&acc, round, current_tool.as_deref(), last_tool.as_deref(), tool_steps, None, progress_snapshot)).await { if update_card(&ctx.client, pid, &stream_preview(&acc, round, current_tool.as_deref(), last_tool.as_deref(), tool_steps, None, progress_snapshot)).await {
@@ -1311,48 +1340,46 @@ async fn run_stream_refresher(
painted_chars = painted_chars.saturating_sub(1); // 标脏 painted_chars = painted_chars.saturating_sub(1); // 标脏
} }
} }
_ => {} // AiHeartbeat 等引擎保活事件不重置内容静默钟:否则 20s 心跳节拍会把静默
// 永远压在阈值内,13a-2 心跳行永不触发(静默指内容进展停顿,非事件停顿)
_ => continue,
} }
// 13a-2 活动时钟:本 conv 任意事件(delta/轮次/工具/进度)都算活着,重置静默计时 // 13a-2 活动时钟:本 conv 内容事件(delta/轮次/工具/进度)算活着,重置静默计时
last_activity = tokio::time::Instant::now(); last_activity = tokio::time::Instant::now();
} }
// 预览允许丢帧:终版事实源在通知桥 oneshot,消费落后只降级预览不断链路 // 预览允许丢帧:终版事实源在帧通道,消费落后只降级预览不断链路
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("[feishu] 流式预览消费落后,丢弃 {n} 条增量(预览降级,终版不受影响)"); tracing::warn!("[feishu] 流式预览消费落后,丢弃 {n} 条增量(预览降级,终版不受影响)");
} }
// 总线随 AppState 常驻理论不关;防御性收口:只等终版,不再消费增量 // 总线随 AppState 常驻理论不关;防御性收口:总线关闭即收工(进程收尾)
Err(tokio::sync::broadcast::error::RecvError::Closed) => { Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
if let Ok(text) = deliver_rx.await {
deliver_result(&ctx.client, &placeholder_id, &message_id, &chat_id, &text).await;
}
return;
}
}, },
_ = ticker.tick() => { _ = ticker.tick() => {
// 受理卡未落地前不刷:防预览/心跳帧抢先覆盖受理卡
if !started {
continue;
}
// 占位缺失(首张卡片 reply 失败)时无从原地刷,保持静默等终版走新发回退 // 占位缺失(首张卡片 reply 失败)时无从原地刷,保持静默等终版走新发回退
let Some(pid) = placeholder_id.as_deref().filter(|id| !id.is_empty()) else { let Some(pid) = placeholder_id.as_deref().filter(|id| !id.is_empty()) else {
continue; continue;
}; };
// ── 13a-2 静默期心跳:超阈值无事件强制刷心跳行,防卡片冻结疑似卡死 ── // ── 13a-2 静默期心跳:内容静默达阈值且进入新静默分钟桶 → 刷心跳行(纯函数判定) ──
// 长工具/LLM 长往返期间无 delta 无轮次推进,卡片静止;心跳行给出「引擎活着+ // 长工具/LLM 长往返期间无 delta 无轮次推进,卡片静止;心跳行给出「引擎活着+
// 进行到哪」证据。桶去重:每静默分钟只刷一次(silent_secs/60 桶号相等即同桶) // 进行到哪」证据。桶去重保证每静默分钟只刷一次,内容恢复即自然停
let silent_secs = last_activity.elapsed().as_secs(); let silent_secs = last_activity.elapsed().as_secs();
let hb_needed = silent_secs >= HEARTBEAT_SILENT_THRESHOLD; let hb_bucket = heartbeat_due(silent_secs, painted_hb_bucket, HEARTBEAT_SILENT_THRESHOLD);
let hb_bucket = silent_secs / 60; let hb_needed = hb_bucket.is_some();
if hb_needed && Some(hb_bucket) == painted_hb_bucket {
continue; // 本静默分钟已刷过心跳,去重
}
// 常规信号标脏:过程事件已回拨 painted_chars,字符数不等即刷 // 常规信号标脏:过程事件已回拨 painted_chars,字符数不等即刷
let dirty = acc_chars != painted_chars; let dirty = acc_chars != painted_chars;
if !dirty && !hb_needed { if !dirty && !hb_needed {
continue; continue;
} }
// 签名去重:正文长度与轨迹行均未变则跳过(防同 tick 内事件风暴空刷) // 签名去重:正文长度与轨迹行均未变则跳过(防同 tick 内事件风暴空刷)
let sig = format!("{}|{}|{}|{}|{}", acc_chars, round.unwrap_or(0), current_tool.as_deref().unwrap_or(""), tool_steps, if hb_needed { hb_bucket } else { u64::MAX }); let sig = format!("{}|{}|{}|{}|{}", acc_chars, round.unwrap_or(0), current_tool.as_deref().unwrap_or(""), tool_steps, hb_bucket.unwrap_or(u64::MAX));
if sig == painted_sig { if sig == painted_sig {
continue; continue;
} }
// 13a-1 刷新可观测:刷失败不再静默——feishu_log 落盘+连败计数,≥3 连败告警一次(计数继续累计, // 13a-1 刷新可观测:刷失败落盘+连败计数,≥3 连败告警一次(计数继续累计,
// 重置仅在成功后;防每 tick 重复告警刷屏)。排查凭据:刷了没刷/刷败原因全可查 // 重置仅在成功后;防每 tick 重复告警刷屏)。排查凭据:刷了没刷/刷败原因全可查
let silent_for_preview = if hb_needed { Some(silent_secs) } else { None }; let silent_for_preview = if hb_needed { Some(silent_secs) } else { None };
let progress_snapshot = progress_round.map(|pr| (pr, progress_max, progress_tokens)); let progress_snapshot = progress_round.map(|pr| (pr, progress_max, progress_tokens));
@@ -1363,8 +1390,8 @@ _ = ticker.tick() => {
refresh_failures = 0; refresh_failures = 0;
painted_chars = acc_chars; painted_chars = acc_chars;
painted_sig = sig; painted_sig = sig;
if hb_needed { if let Some(b) = hb_bucket {
painted_hb_bucket = Some(hb_bucket); // 心跳桶已刷,本静默分钟不再刷 painted_hb_bucket = Some(b); // 心跳桶已刷,本静默分钟不再刷
} }
} else { } else {
refresh_failures += 1; refresh_failures += 1;
@@ -1373,7 +1400,7 @@ _ = ticker.tick() => {
)); ));
if refresh_failures == REFRESH_FAIL_ALERT_THRESHOLD { if refresh_failures == REFRESH_FAIL_ALERT_THRESHOLD {
feishu_log(&format!( feishu_log(&format!(
"[refresher] ⚠️ 连续 {refresh_failures} 次刷新失败,卡片可能冻结——流式预览降级,终版仍走通知桥(不受影响)" "[refresher] ⚠️ 连续 {refresh_failures} 次刷新失败,卡片可能冻结——流式预览降级,终版仍走帧通道(不受影响)"
)); ));
} }
} }
@@ -1931,7 +1958,7 @@ async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap<String, Stri
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
is_terminal_push, md_to_lark_friendly, msg_after_acceptance, parse_command, heartbeat_due, is_terminal_push, md_to_lark_friendly, msg_after_acceptance, parse_command,
strip_mention_placeholders, stream_preview, strip_mention_placeholders, stream_preview,
}; };
@@ -2069,6 +2096,16 @@ mod tests {
assert!(out.starts_with("💓 **仍在跑:第 15 轮 · 最近动作 `3 步工具调用` · 静默 72s**\n\n")); assert!(out.starts_with("💓 **仍在跑:第 15 轮 · 最近动作 `3 步工具调用` · 静默 72s**\n\n"));
} }
#[test]
fn heartbeat_due_threshold_and_bucket_dedup() {
// 13a-2 判定纯函数:未达阈值不刷;达阈值且新桶刷;同静默分钟桶去重;进新桶再刷
assert_eq!(heartbeat_due(59, None, 60), None);
assert_eq!(heartbeat_due(60, None, 60), Some(1));
assert_eq!(heartbeat_due(60, Some(1), 60), None);
assert_eq!(heartbeat_due(119, Some(1), 60), None); // 119/60=1 同桶
assert_eq!(heartbeat_due(120, Some(1), 60), Some(2));
}
// ── 38a 出站 md 净化 ── // ── 38a 出站 md 净化 ──
#[test] #[test]
+15
View File
@@ -9,6 +9,7 @@
class="update-badge-btn" class="update-badge-btn"
:class="{ 'is-active': expanded }" :class="{ 'is-active': expanded }"
:title="$t('settings.updateBadgeTitle')" :title="$t('settings.updateBadgeTitle')"
:aria-expanded="expanded"
@click="expanded = !expanded" @click="expanded = !expanded"
> >
<span class="update-badge-dot"></span> <span class="update-badge-dot"></span>
@@ -67,6 +68,15 @@ const installError = ref('')
let unlistenAvailable: UnlistenFn | null = null let unlistenAvailable: UnlistenFn | null = null
let unlistenProgress: UnlistenFn | null = null let unlistenProgress: UnlistenFn | null = null
/** window CustomEvent 形态的更新通知:生产者 useUpdateCheck 用浏览器事件广播(非 Tauri emit),
* 与 Tauri 监听同名不同通道,故单列此处理器接住它。 */
function onUpdateAvailable(e: Event) {
const detail = (e as CustomEvent<UpdateAvailablePayload>).detail
if (!detail) return
info.value = detail
visible.value = true
}
onMounted(async () => { onMounted(async () => {
try { try {
unlistenAvailable = await listen<UpdateAvailablePayload>('app-update-available', (e) => { unlistenAvailable = await listen<UpdateAvailablePayload>('app-update-available', (e) => {
@@ -76,6 +86,10 @@ onMounted(async () => {
} catch { } catch {
/* 静默 */ /* 静默 */
} }
// 通道对齐(2026-10-10 实证):生产者 useUpdateCheck 走的是**浏览器 window CustomEvent**
// (同名 'app-update-available'),与上面的 Tauri listen 不是同一通道 → 徽章此前永不触发。
// 保留 Tauri 监听(后端将来若改走 emit 亦可用),同时挂 window 监听接住现有生产者。
window.addEventListener('app-update-available', onUpdateAvailable as EventListener)
try { try {
unlistenProgress = await listen<{ stage: string; downloaded: number; total: number | null }>( unlistenProgress = await listen<{ stage: string; downloaded: number; total: number | null }>(
'app-update-progress', 'app-update-progress',
@@ -93,6 +107,7 @@ onUnmounted(() => {
unlistenProgress?.() unlistenProgress?.()
unlistenAvailable = null unlistenAvailable = null
unlistenProgress = null unlistenProgress = null
window.removeEventListener('app-update-available', onUpdateAvailable as EventListener)
}) })
const progressPercent = computed(() => { const progressPercent = computed(() => {
+25 -3
View File
@@ -1,13 +1,21 @@
<template> <template>
<div class="project-card" :class="{ 'project-card--list': view === 'list' }" @click="router.push('/projects/' + project.id)"> <div
class="project-card"
:class="{ 'project-card--list': view === 'list' }"
role="button"
tabindex="0"
@click="openProject"
@keydown.enter.prevent="onCardKey"
@keydown.space.prevent="onCardKey"
>
<div class="card-top"> <div class="card-top">
<div class="card-title-row"> <div class="card-title-row">
<h2 class="card-name">{{ project.name }}</h2> <h2 class="card-name">{{ project.name }}</h2>
</div> </div>
<div class="card-top-actions"> <div class="card-top-actions">
<span class="stage-badge" :class="stageClass(project.status)">{{ $t(statusLabel(project.status)) }}</span> <span class="stage-badge" :class="stageClass(project.status)">{{ $t(statusLabel(project.status)) }}</span>
<button class="card-del-btn" :title="$t('projects.archiveTitle')" @click.stop="emit('archive', project)">📦</button> <button class="card-del-btn" :title="$t('projects.archiveTitle')" :aria-label="$t('projects.archiveTitle')" @click.stop="emit('archive', project)">📦</button>
<button class="card-del-btn" :title="$t('projects.deleteTitle')" @click.stop="emit('delete', project)">🗑</button> <button class="card-del-btn" :title="$t('projects.deleteTitle')" :aria-label="$t('projects.deleteTitle')" @click.stop="emit('delete', project)">🗑</button>
</div> </div>
</div> </div>
@@ -74,6 +82,20 @@ const emit = defineEmits<{
const router = useRouter() const router = useRouter()
// 整卡点击进详情:可点根元素用 role/tabindex 兜底(卡内嵌套删除/归档按钮,不能整体换 <button>),
// 键盘与鼠标复用同一 handler
function openProject() {
router.push('/projects/' + props.project.id)
}
/** 卡片根键盘触发:仅当事件源就是根本身才开详情——卡内嵌套的归档/删除/展开按钮聚焦后按
* Enter/Space 会冒泡到此,原实现(.prevent 直接调 openProject)会把「删除」顶替成「跳转详情」
* 并取消按钮自身激活(2026-10-10 a11y 对抗核验实测缺陷)。 */
function onCardKey(e: KeyboardEvent) {
if (e.target !== e.currentTarget) return
openProject()
}
// 工程数(异步拉取,展示在卡片底部) // 工程数(异步拉取,展示在卡片底部)
const moduleCount = ref(0) const moduleCount = ref(0)
// UX走查 P3-6: 加载态标记,模板据此显骨架占位而非 0(成功/失败都收 loading,失败仍显 0 属既有约定) // UX走查 P3-6: 加载态标记,模板据此显骨架占位而非 0(成功/失败都收 loading,失败仍显 0 属既有约定)
+3 -2
View File
@@ -18,7 +18,7 @@ import { aiApi } from '@/api'
import { state } from '@/stores/ai' import { state } from '@/stores/ai'
import { t } from '@/i18n/i18n-helpers' import { t } from '@/i18n/i18n-helpers'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream' import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { findToolCall, removePendingApprovalById } from './aiShared' import { bumpToolEpoch, findToolCall, removePendingApprovalById } from './aiShared'
import { dispatchApprovalIPC } from './useToolApproval' import { dispatchApprovalIPC } from './useToolApproval'
/** /**
@@ -87,7 +87,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
tc.status = isDeny ? 'rejected' : 'running' tc.status = isDeny ? 'rejected' : 'running'
} }
if (isDeny) { if (isDeny) {
tc.result = t('ai.aiTool.rejectedHint') tc.result = t('aiTool.rejectedHint')
} }
} }
// 同步从待审批集合移除(含 per-conv 集合;不等后端 AiApprovalResult 事件来移,防 loading 卡死) // 同步从待审批集合移除(含 per-conv 集合;不等后端 AiApprovalResult 事件来移,防 loading 卡死)
@@ -108,6 +108,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
if (tc) { if (tc) {
tc.status = 'completed' tc.status = 'completed'
tc.result = t('ai.approvalNotDelivered', { error: errMsg }) tc.result = t('ai.approvalNotDelivered', { error: errMsg })
bumpToolEpoch()
} }
throw e throw e
} }