重构: 巨函数拆分 + 清理历史标记注释 + custom_prompt/停止按钮/tunnel 改进
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
//!
|
||||
//! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。
|
||||
//!
|
||||
//! 流程(对齐阶段2 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
|
||||
//! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
|
||||
//! ① 读 active 克隆(不改 status / 不扣 token)→ 喂 LLM 出摘要;
|
||||
//! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system);
|
||||
//! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。
|
||||
@@ -17,11 +17,11 @@
|
||||
//! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁,但 loop 串行无并发
|
||||
//! (本函数独占 session_arc,工具执行/审批分支在 stream 之后),故此窗口内 messages 不变。
|
||||
//!
|
||||
//! 安全(FR-S1):复用 loop 顶部已 build+验证 的 provider(不再 build_provider_for 重复 resolve
|
||||
//! 安全:复用 loop 顶部已 build+验证 的 provider(不再 build_provider_for 重复 resolve
|
||||
//! keyring),api_key 经 df_storage::secret 闭环;summary/error payload/日志均不含 api_key。
|
||||
//! is_compressing 防重入:set_compressing(true/false) 成对(LLM 调用前后均复位)。
|
||||
//! 单轮问答(history_tokens 未超 0.6*budget)不触发,零行为变化。
|
||||
//! F-260616-09 B 批2:messages 操作改 per_conv(设计 §4.2)。
|
||||
//! messages 操作改 per_conv。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -51,7 +51,7 @@ const PROTECT_COUNT: usize = 6;
|
||||
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
|
||||
const KEYWORD_FALLBACK_ENABLED: bool = true;
|
||||
|
||||
/// F-15 阶段3: 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
|
||||
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
|
||||
///
|
||||
/// 从 `run_agentic_loop` 抽取,行为零变更。返回值:
|
||||
/// - `true`:conv 已删除,调用方应立即 `return`(退出整个 loop)。
|
||||
@@ -100,7 +100,7 @@ pub(super) async fn maybe_auto_compress(
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
// L3 emit 双写:tunnel subscriber透传 miniapp
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
let (active_msgs, lang) = {
|
||||
let mut session = session_arc.lock().await;
|
||||
@@ -168,7 +168,7 @@ pub(super) async fn maybe_auto_compress(
|
||||
summary: summary.nl_summary,
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
// L3 emit 双写:tunnel subscriber透传 miniapp
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
Ok(None) => {
|
||||
@@ -216,7 +216,7 @@ pub(super) async fn maybe_auto_compress(
|
||||
);
|
||||
}
|
||||
}
|
||||
// BUG-260624-05:压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
|
||||
// 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
|
||||
// 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn(上方 line 957 已有)
|
||||
// + compress_old_messages 兜底释放 token + build_for_request 裁剪,loop 继续 stream_llm,
|
||||
// 用户自然看到后续回复。压缩降级对用户透明(非致命错误,不该停流)。
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
//! - **读侧收敛**:停止按钮三态(可停 / 停中 / 停失败可重试)、MaxRoundsCard 是否弹等
|
||||
//! 判别逻辑由 `ConvState` 变体直接表达,不再靠多变量组合反推。
|
||||
//!
|
||||
//! # 收口后(批3+)
|
||||
//! # 当前架构
|
||||
//!
|
||||
//! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫。批3 双轨收口后 `generating` bool
|
||||
//! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫。`generating` bool
|
||||
//! 与 `CONV_STATE_ENABLED` 开关已退役,`ConvState` 成为唯一真相源:guard 接入点
|
||||
//! (`GeneratingGuard` new/reset/drop 时同步迁移 `ConvState`)无条件执行迁移 + emit。
|
||||
//!
|
||||
@@ -52,7 +52,7 @@ use serde::{Deserialize, Serialize};
|
||||
/// `ConvState` 是面向「生成生命周期」的写侧真相(写收敛)。两者正交:如 `Generating` 态
|
||||
/// 同时有审批挂起时,`ConvState=Generating` 而 `SessionState=AwaitingApproval`。
|
||||
///
|
||||
/// 序列化(`Serialize`/`Deserialize`):批3 前端经事件总线读 enum 视图时使用(本批未接,
|
||||
/// 序列化(`Serialize`/`Deserialize`):前端经事件总线读 enum 视图时使用(本批未接,
|
||||
/// 预留,避免后续改动序列化兼容性)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -150,9 +150,7 @@ impl ConvState {
|
||||
/// 审批挂起(读视图 `SessionState::AwaitingApproval`)期间 `ConvState` 仍是 `Generating`,
|
||||
/// 故本方法返回 true——审批挂起不算「停止生成」。
|
||||
///
|
||||
/// 预留:批2+ 读侧迁移(try_continue / ai_chat_stop / 前端停止三态)消费。
|
||||
/// L2 读侧迁移(2026-06-22):已接入 ai_is_generating / ai_chat_stop / try_continue_agent_loop,
|
||||
/// 不再标 allow(dead_code)(有真实消费方)。
|
||||
/// 已接入 ai_is_generating / ai_chat_stop / try_continue_agent_loop,不再标 allow(dead_code)。
|
||||
pub fn is_active(self) -> bool {
|
||||
matches!(self, ConvState::Generating | ConvState::Compressed)
|
||||
}
|
||||
@@ -162,8 +160,8 @@ impl ConvState {
|
||||
/// 读侧便利方法:用于 `ai_conversation_create` / `ai_chat_send` 等入口判断
|
||||
/// 「能否接新请求」。`Error` 态视为可接(用户重试即从 Error 起步)。
|
||||
///
|
||||
/// 双轨收口批1(2026-06-25):chat 域入口拦截(ai_regenerate / ai_chat_send /
|
||||
/// ai_chat_edit)已接入,作真实读侧方法消费。删除 `allow(dead_code)` 标注。
|
||||
/// chat 域入口拦截(ai_regenerate / ai_chat_send /
|
||||
/// ai_chat_edit)已接入,作真实读侧方法消费。
|
||||
pub fn can_accept_request(self) -> bool {
|
||||
matches!(self, ConvState::Idle | ConvState::Error)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::commands::ai::knowledge_inject::inject_knowledge_into_prompt;
|
||||
use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry};
|
||||
use super::conv_state::ConvState;
|
||||
|
||||
/// BUG-260617-05: try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
|
||||
/// try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
|
||||
struct ContinueSnapshot {
|
||||
is_generating: bool,
|
||||
has_pending: bool,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
//! F-#97 / AE-04 / 阶段4 审批门控逻辑。
|
||||
//! 审批门控逻辑。
|
||||
//!
|
||||
//! 审批策略(方案 B:Persona 维度 + 扩展点):
|
||||
//! - reviewer/analyst 人设的工具调用全 auto(只读/分析,审批无意义)
|
||||
@@ -88,12 +88,12 @@ pub(super) fn should_auto_for_persona(
|
||||
classify_risk_and_auto(risk_level, auto_exec_mode, tool_name, args)
|
||||
}
|
||||
|
||||
/// AE-2025-04 检查会话信任命中。
|
||||
/// 检查会话信任命中。
|
||||
///
|
||||
/// 首批信任工具:write_file / run_command。同会话已批准过同工具+同目录 →
|
||||
/// `TrustKey` 命中,返回 `Some(TrustKey)`;否则返回 `None`(走原审批流程)。
|
||||
///
|
||||
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,内部短 lock 读
|
||||
/// 签名改 `session_arc: &Arc<Mutex<AiSession>>`,内部短 lock 读
|
||||
/// `session_trust` 后立即 drop,信任查询不持锁,与 process_tool_calls 持锁 await 反模式解耦。
|
||||
pub(super) async fn check_trust_hits(
|
||||
draft: &ToolCallDraft,
|
||||
@@ -118,7 +118,7 @@ pub(super) async fn check_trust_hits(
|
||||
/// **注意**:调用方应在调用前先 +=1 `pending_count`(保持与原 `handle_approval_tool`
|
||||
/// 行为一致——重试 skip 分支也在 `pending_count += 1` 之后返回)。
|
||||
///
|
||||
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有慢操作
|
||||
/// 签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有慢操作
|
||||
/// (build_write_file_diff/detect_retry_count/build_approval_reason/audit_tool_call)
|
||||
/// 在锁外 await,仅 `pending_approvals.insert` + `messages.push` 两处纯写改短 lock 段。
|
||||
pub(super) async fn insert_pending_approval(
|
||||
@@ -132,7 +132,7 @@ pub(super) async fn insert_pending_approval(
|
||||
db: &Arc<Database>,
|
||||
current_message_id: Option<&str>,
|
||||
) {
|
||||
// AE-2025-03(路径 B):write_file 挂起审批前预读旧文件生成 diff。
|
||||
// (路径 B):write_file 挂起审批前预读旧文件生成 diff。
|
||||
// 仅 write_file(覆盖整文件,有完整新旧内容可对比);其他工具 diff=None。
|
||||
// 旧文件不存在(新建)→ diff=None,前端回退显新 content。
|
||||
// 读失败不阻断审批(容错:文件无读权限等极端情况降级为无 diff 预览)。
|
||||
@@ -142,7 +142,7 @@ pub(super) async fn insert_pending_approval(
|
||||
None
|
||||
};
|
||||
|
||||
// 阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
||||
// (容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
||||
// 与 High risk 的 find_cached_high_risk_result 互补:去重按 (tool_name,args) 匹配
|
||||
// (High only),本 guard 按 tc_id 匹配(覆盖 Med + High 残留场景)。
|
||||
// 同 tc_id 已有审计落定记录 → retry_count≥1,跳过审批 + emit Completed,断死循环。
|
||||
@@ -179,12 +179,12 @@ pub(super) async fn insert_pending_approval(
|
||||
arguments: args.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
recovered: false,
|
||||
// 阶段3a:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。
|
||||
// 普通风险审批标 kind=Risk{diff}(下沉原 diff 字段)。
|
||||
kind: ApprovalKind::Risk { diff: approval_diff.clone() },
|
||||
retry_count,
|
||||
created_at: Some(std::time::SystemTime::now()),
|
||||
});
|
||||
// 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan)
|
||||
// 占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan)
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||||
}
|
||||
// 慢操作锁外:拼 reason(DB 读)+ emit + 审计落 pending 纪录(DB 写)
|
||||
@@ -203,7 +203,7 @@ pub(super) async fn insert_pending_approval(
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
|
||||
}
|
||||
|
||||
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 tc_id 重试计数。
|
||||
/// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 tc_id 重试计数。
|
||||
///
|
||||
/// 返回语义:
|
||||
/// - 0:审计表无该 tc_id 落定记录(或仅 pending),属首次审批执行,正常挂起。
|
||||
@@ -224,7 +224,7 @@ pub(super) async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id:
|
||||
None => return 0, // 无记录 = 首次
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("[阶段4-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e);
|
||||
tracing::warn!("[approval-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
@@ -240,10 +240,10 @@ pub(super) async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id:
|
||||
///
|
||||
/// 1. 按 `auto_exec_mode`(low/medium/all) + `risk_level` + `patch_file` 小改动特例
|
||||
/// 判定是否应自动执行(`classify_risk_and_auto`)。若是 → 推入 `low_risk` 返回。
|
||||
/// 2. 否则走审批分支:会话信任(`check_trust_hits`)→ F-05 高危去重缓存 → 阶段4 重试 guard →
|
||||
/// 2. 否则走审批分支:会话信任(`check_trust_hits`)→ 高危去重缓存 → 重试 guard →
|
||||
/// `insert_pending_approval`(write_file diff + 挂起 + emit + 审计落 pending 记录)。
|
||||
///
|
||||
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有 session 访问
|
||||
/// 签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有 session 访问
|
||||
/// 都改短 lock 段(check_trust_hits/find_cached_high_risk_result 内部短 lock,命中后 push
|
||||
/// tool_result 短 lock 段)。慢操作(audit_tool_call/find_cached_high_risk_result 的 DB 查)
|
||||
/// 全在锁外 await,根治 process_tool_calls 持 session lock 期间 await 慢操作死锁反模式。
|
||||
@@ -270,9 +270,9 @@ pub(super) async fn handle_approval_tool(
|
||||
}
|
||||
|
||||
// ── Step 2: 会话信任检查 ──
|
||||
// AE-2025-04 会话级信任(Session Trust):首批 write_file / run_command,
|
||||
// 会话级信任(Session Trust):首批 write_file / run_command,
|
||||
// 同会话已批准过同工具+同目录 → TrustKey 命中 → 自动放行(跳过 pending + 二次确认)。
|
||||
// 命中后走与 F-05 去重命中相似的「直接执行 + Completed + 审计 decided_by=auto_trust」路径,
|
||||
// 命中后走与去重命中相似的「直接执行 + Completed + 审计 decided_by=auto_trust」路径,
|
||||
// 但与 F-05 不同:F-05 复用缓存 tool_result 跳过执行;trust 放行**真实执行工具**
|
||||
// (用户信任同目录同类操作,但仍要看每次的真实结果)。
|
||||
if let Some(key) = check_trust_hits(&draft, &args, session_arc, conv_id).await {
|
||||
@@ -283,7 +283,7 @@ pub(super) async fn handle_approval_tool(
|
||||
tool = %draft.name,
|
||||
dir = %dir_label,
|
||||
new_tool_call_id = %draft.id,
|
||||
"[AE-2025-04] 会话信任命中: 同会话已批准同类操作,自动放行(跳过审批+二次确认)"
|
||||
"[会话信任] 命中: 同会话已批准同类操作,自动放行(跳过审批+二次确认)"
|
||||
);
|
||||
// emit 轻量 toast 事件(前端 AiChat.vue 显示"🔓 自动放行: tool(dir)")
|
||||
// L3 emit 双写:会话信任自动放行 toast 双路发布(tunnel 透传 miniapp 即时反馈)。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! F-260616-05 高危工具去重缓存(第三批 helper 抽离,行为零变更)。
|
||||
//! 高危工具去重缓存。
|
||||
//!
|
||||
//! 从 audit/mod.rs 搬迁:`find_cached_high_risk_result` + `canonical_args_key`
|
||||
//! + `sort_object_keys` + `PENDING_APPROVAL_PLACEHOLDER`(去重与 process_tool_calls
|
||||
@@ -18,14 +18,14 @@ use super::super::AiSession;
|
||||
/// (find_cached_high_risk_result) 各持一份字面量致耦合——若两者漂移,
|
||||
/// 去重会把 pending 占位误判为已落定结果命中缓存,污染 LLM 上下文。
|
||||
///
|
||||
/// **阶段2(占位配对完整性)**:实际写入 messages 时用 [`pending_placeholder_for`] 生成带
|
||||
/// **占位配对完整性**:实际写入 messages 时用 [`pending_placeholder_for`] 生成带
|
||||
/// `__PENDING__:tc_id` 标记的完整占位文本,供 sanitize 识别"占位不可裁 + 出口断言自愈补头"。
|
||||
/// 本常量保留为基础文本(向前兼容 + 去重匹配基准)。
|
||||
pub(crate) const PENDING_APPROVAL_PLACEHOLDER: &str = "需要用户审批,等待确认";
|
||||
|
||||
/// 构造带唯一标记(`__PENDING__:tc_id`)的审批挂起占位 tool_result 内容。
|
||||
///
|
||||
/// 阶段2 解 400 orphan:占位 tool_result 内嵌 tc_id 标记,sanitize step3.5(反向 orphan 检测)
|
||||
/// 解 400 orphan:占位 tool_result 内嵌 tc_id 标记,sanitize step3.5(反向 orphan 检测)
|
||||
/// 据此把占位豁免保留(不丢),出口断言 `assert_placeholder_pairing` 据此补 TOOL_MISSING_PREFIX
|
||||
/// 占位头自愈,使占位 result 与(可能被裁掉的)tool_call 头闭合配对,防 provider 400 orphan。
|
||||
///
|
||||
@@ -40,15 +40,15 @@ pub(crate) fn pending_placeholder_for(tc_id: &str) -> String {
|
||||
|
||||
/// 判定 tool_result content 是否为审批挂起占位(含基础文本或带 __PENDING__ 标记)。
|
||||
///
|
||||
/// 替代原裸 `msg.content == PENDING_APPROVAL_PLACEHOLDER` 精确匹配(阶段2 占位带 tc_id 标记后
|
||||
/// 替代原裸 `msg.content == PENDING_APPROVAL_PLACEHOLDER` 精确匹配(占位带 tc_id 标记后
|
||||
/// 不再精确等于基础文本,需用子串/标记匹配)。兼容老占位(纯文本)与新占位(带标记)。
|
||||
pub(crate) fn is_pending_placeholder(content: &str) -> bool {
|
||||
df_ai::context_helpers::is_pending_placeholder(content)
|
||||
}
|
||||
|
||||
/// F-260616-05:高危工具去重(根治 run_command 超时→重试→重新审批循环)。
|
||||
/// 高危工具去重(根治 run_command 超时→重试→重新审批循环)。
|
||||
///
|
||||
/// ⚠ 性能注记(CR-260618-11#5):本函数在 session lock 持有期间对每个 high risk 工具
|
||||
/// ⚠ 性能注记:本函数在 session lock 持有期间对每个 high risk 工具
|
||||
/// 串行查 AiToolExecutionRepo::find_by_tool_call_id,工具数多时锁持有线性增长。
|
||||
/// 批量预取(改签名传 tool_call_ids 批量查)属架构改暂未做,后续 High risk 工具增多时优先处理。
|
||||
///
|
||||
@@ -70,7 +70,7 @@ pub(crate) fn is_pending_placeholder(content: &str) -> bool {
|
||||
///
|
||||
/// `session` 只读扫描 messages(不写),调用方据返回值决定是否跳过 insert pending。
|
||||
///
|
||||
/// F-260616-09 B 批2:经`conv_id` 索引 per_conv.messages(顶层 messages 批2 后是死字段)。
|
||||
/// 经`conv_id` 索引 per_conv.messages(顶层 messages 在拆分存储后是死字段)。
|
||||
/// conv_id 来源:process_tool_calls 入参 → 由 agentic/mod.rs run_agentic_loop 入参透传。
|
||||
pub(crate) async fn find_cached_high_risk_result(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
@@ -84,12 +84,12 @@ pub(crate) async fn find_cached_high_risk_result(
|
||||
// 规范化新调用的 args 为可比字符串(排序键,键序无关)
|
||||
let new_args_key = canonical_args_key(args);
|
||||
|
||||
// BUG-260624-03/P0 重构:短 lock 段读 messages + 找旧 tool_call_id + 旧 tool_result content,
|
||||
// 短 lock 段读 messages + 找旧 tool_call_id + 旧 tool_result content,
|
||||
// drop 锁后再锁外 await DB 查 status(原代码持锁 await audit_repo,违反持锁 await 慢操作禁令)。
|
||||
// 第一步:锁内(async block 包裹,出 block 自动 drop guard)反向扫描,定位旧 tool_call_id 与对应 tool_result content
|
||||
let cached: Option<(String, String)> = (async {
|
||||
let session = session_arc.lock().await;
|
||||
// F-260616-09 B 批2:读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv,
|
||||
// 读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv,
|
||||
// 故 conv_read 必命中;防御性 None 时返 None(无缓存命中,走原审批流程)。
|
||||
let Some(conv) = session.conv_read(conv_id) else { return None };
|
||||
// ContextManager::iter 返回 impl Iterator(非 DoubleEnded),collect 成 Vec 再反向遍历。
|
||||
@@ -134,7 +134,7 @@ pub(crate) async fn find_cached_high_risk_result(
|
||||
continue;
|
||||
}
|
||||
// 命中旧 tool_result:排除 pending 占位(基础文本或带 __PENDING__:tc_id 标记)
|
||||
// 阶段2:占位带标记后不再精确等于基础文本,改用 is_pending_placeholder 匹配。
|
||||
// 占位带标记后不再精确等于基础文本,改用 is_pending_placeholder 匹配。
|
||||
// 加固(子串误伤):`__PENDING__` 标记是 audit/cache.rs 占位模板独占信号(权威判定);
|
||||
// 老占位分支已收紧为精确全文等值(非 starts_with 前缀),杜绝用户真实 tool_result 内容
|
||||
// 恰以"需要用户审批..."开头被误判占位致去重误吞(详见 context_helpers::is_pending_placeholder)。
|
||||
@@ -167,7 +167,7 @@ fn canonical_args_key(args: &serde_json::Value) -> String {
|
||||
serde_json::to_string(&v).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// BUG-2026-07-07/P0-2:只读幂等工具结果缓存(治 LLM 死循环重调)。
|
||||
/// 只读幂等工具结果缓存(治 LLM 死循环重调)。
|
||||
///
|
||||
/// **根因链(实测 9357c27c)**:LLM 无"已调用过"记忆,对同参只读工具反复触发:
|
||||
/// - `read_file(application.xml)` 读了 17 次(两次返回完全相同的 8457c 全文)
|
||||
@@ -205,7 +205,7 @@ pub(crate) async fn find_cached_readonly_result(
|
||||
|
||||
let new_args_key = canonical_args_key(args);
|
||||
|
||||
// BUG-260624-03/P0 重构:短 lock 段读 messages + 锁外 await DB 查 status(原代码持锁 await)
|
||||
// 短 lock 段读 messages + 锁外 await DB 查 status(原代码持锁 await)
|
||||
let cached: Option<(String, String)> = (async {
|
||||
let session = session_arc.lock().await;
|
||||
let Some(conv) = session.conv_read(conv_id) else { return None };
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
//! AE-2025-03(路径 B):write_file 审批预览 diff 生成。
|
||||
//! write_file 审批预览 diff 生成。
|
||||
//!
|
||||
//! 第五批从 audit/mod.rs 抽离,行为零变更。`build_write_file_diff` 仅 process_tool_calls
|
||||
//! 在 write_file 高风险审批分支裸名调用,依赖 `super::super::tool_registry::generate_diff`
|
||||
//! (F-260615-10 LCS 行级 diff),故 diff.rs 直接 `use super::super::tool_registry::generate_diff`。
|
||||
//! (LCS 行级 diff),故 diff.rs 直接 `use super::super::tool_registry::generate_diff`。
|
||||
//!
|
||||
//! re-export 保持原路径透明:audit/mod.rs 通过 `use diff::build_write_file_diff` 裸名调用。
|
||||
|
||||
use super::super::tool_registry::generate_diff;
|
||||
|
||||
/// 从 write_file args 取 path(旧文件路径)+ content(新内容),
|
||||
/// 预读旧文件 → 复用 `generate_diff`(F-260615-10 LCS 行级 diff)注入审批事件。
|
||||
/// 预读旧文件 → 复用 `generate_diff`(LCS 行级 diff)注入审批事件。
|
||||
/// 旧文件不存在(新建)/ 读失败 / args 缺字段 → None(前端回退显新 content)。
|
||||
///
|
||||
/// **仅读不改**:审批未通过前不动文件;读路径不校验(write_file handler 自身会校验
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::state::AppState;
|
||||
/// 导致审批后审计记录永久卡 pending。
|
||||
pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status: &str, result: Option<String>) {
|
||||
// 区分 Err(DB 故障)与 Ok(None)(真无记录):原 unwrap_or_default 把 Err 压成 None,
|
||||
// DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索(B-260617-17 同款吞错)。
|
||||
// DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索。
|
||||
let mut rec = match state.ai_tool_executions.find_by_tool_call_id(tool_call_id).await {
|
||||
Ok(Some(rec)) => rec,
|
||||
Ok(None) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! F-260619-04 P2(方案 B): 灵感来源(create_idea)消息级溯源补全。
|
||||
//! 灵感来源(create_idea)消息级溯源补全。
|
||||
//!
|
||||
//! ## 背景
|
||||
//! P1 把知识/审计的 `source_ref` 升级到消息级(`conv_msg:{id}`),但**灵感 `create_idea`
|
||||
|
||||
@@ -20,16 +20,16 @@ use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApprov
|
||||
mod utils;
|
||||
pub(crate) use utils::{risk_from_str, risk_str, truncate_chars};
|
||||
|
||||
// diff(audit/diff.rs):AE-2025-03 write_file 审批预览 diff 生成。
|
||||
// diff(audit/diff.rs):write_file 审批预览 diff 生成。
|
||||
// 第五批从本文件抽离,行为零变更。
|
||||
mod diff;
|
||||
|
||||
// path_auth(audit/path_auth.rs):F-260619-03 Phase B/C 路径授权预校验。
|
||||
// path_auth(audit/path_auth.rs):路径授权预校验。
|
||||
// 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。
|
||||
mod path_auth;
|
||||
pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome};
|
||||
|
||||
// approval(audit/approval.rs):F-#97/AE-04/阶段4 审批门控逻辑。
|
||||
// approval(audit/approval.rs):审批门控逻辑。
|
||||
// 第六批从本文件抽离,行为零变更。use 保持 process_tool_calls 裸名调用。
|
||||
mod approval;
|
||||
use approval::{detect_retry_count, handle_approval_tool};
|
||||
@@ -63,8 +63,8 @@ pub use restore::restore_pending_approvals;
|
||||
mod finalize;
|
||||
pub(crate) use finalize::audit_finalize;
|
||||
|
||||
// cache(audit/cache.rs):F-260616-05 高危工具去重缓存 + BUG-2026-07-07/P0-2 只读工具缓存。
|
||||
// 第三批从本文件抽离,行为零变更(P0-2 为新增能力)。
|
||||
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
|
||||
// 第三批从本文件抽离,行为零变更。
|
||||
mod cache;
|
||||
pub(super) use cache::{find_cached_readonly_result, pending_placeholder_for};
|
||||
|
||||
@@ -74,7 +74,7 @@ pub(super) use cache::{find_cached_readonly_result, pending_placeholder_for};
|
||||
mod data_change;
|
||||
pub(crate) use data_change::emit_data_changed;
|
||||
|
||||
// idea_source(audit/idea_source.rs):F-260619-04 P2(方案 B)灵感来源消息级溯源补全。
|
||||
// idea_source(audit/idea_source.rs):灵感来源消息级溯源补全。
|
||||
// create_idea 工具执行后,若 AI 未填 source 且有 message_id → 补 conv_msg:{id}(低侵入,不改 handler 接口)。
|
||||
// pub(crate) use 供本文件 process_tool_calls + chat.rs 审批执行路径调用(单点逻辑,多调用点)。
|
||||
mod idea_source;
|
||||
@@ -89,7 +89,7 @@ pub(crate) use idea_source::maybe_fill_idea_source;
|
||||
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
|
||||
/// stream_llm select! 心跳语义),execute 完 abort 心跳 task。
|
||||
///
|
||||
/// 根治 BUG-260624-03:工具执行在 stream_llm 之外(本模块),原本无 AiHeartbeat。
|
||||
/// 根治:工具执行在 stream_llm 之外(本模块),原本无 AiHeartbeat。
|
||||
/// 单次 execute 超过前端 STREAM_TIMEOUT_MS(130s)——bash 跑 cargo/测试、read 大文件、
|
||||
/// 全盘 search 等开发长命令——前端 watchdog 误判断流,抛"工具已执行完成后续中断"误报
|
||||
/// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。
|
||||
@@ -137,7 +137,7 @@ async fn execute_with_heartbeat(
|
||||
});
|
||||
// RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。
|
||||
let _guard = HeartbeatGuard { stop, handle: heartbeat };
|
||||
// BUG-2026-07-19: tools.execute 无 timeout 时,卡死工具(run_command 长命令/read_file 大文件/同步
|
||||
// tools.execute 无 timeout 时,卡死工具(run_command 长命令/read_file 大文件/同步
|
||||
// 阻塞工具)永久挂起 → process_tool_calls 持 session lock 永久 → guard.reset 等 lock → AiCompleted
|
||||
// 永不发 → 前端"回答完卡住/超时清空"。60s timeout 兜底:超时返错误 tool_result,锁释放,loop 续跑。
|
||||
// 心跳 30s 续命前端 watchdog,60s timeout 覆盖绝大多数工具(run_command 已自带 10s 子超时)。
|
||||
@@ -164,26 +164,26 @@ pub(crate) async fn process_tool_calls(
|
||||
) -> usize {
|
||||
let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect();
|
||||
tc_list.sort_unstable_by_key(|(i, _)| *i);
|
||||
// B-260616-21 治本兜底:LLM 异常复用同 tool_use.id(stream_recv 按 content_block index 分桶,
|
||||
// 治本兜底:LLM 异常复用同 tool_use.id(stream_recv 按 content_block index 分桶,
|
||||
// 同 id 不同 index draft 可并存 → 每 draft emit AiToolCallStarted 致同 id emit 两次 → 前端 push 两卡,
|
||||
// Completed 按 id 只 update 首张 → 次张残留 running 0行)。process 层按 id 去重——同 id 保留
|
||||
// 最小 index 的首个,丢弃后续,保证 emit Started 的 id 唯一。前端 useAiEvents.ts:205 findToolCall
|
||||
// 守卫双保险。详 docs/02-架构设计/已编号方案/B-260616-21排查方案-2026-06-16.md。
|
||||
// 守卫双保险。
|
||||
let mut seen_ids: HashSet<String> = HashSet::new();
|
||||
tc_list.retain(|(_, draft)| seen_ids.insert(draft.id.clone()));
|
||||
let mut pending_count = 0usize;
|
||||
let audit_repo = AiToolExecutionRepo::new(db);
|
||||
|
||||
// F-260619-04 P1 消息级溯源:取当前 assistant 消息 id。
|
||||
// P1 消息级溯源:取当前 assistant 消息 id。
|
||||
// 调用前 agentic/mod.rs 已把本轮 assistant_with_tools 消息(LLM 返回带 tool_calls 的那条)
|
||||
// push 到 per_conv.messages(audit/mod.rs:882),此处取末条 assistant id 作为本轮工具
|
||||
// 调用所属的溯源 message_id,贯穿所有 audit_tool_call 写入。None 表示无 assistant 消息
|
||||
// (异常路径/老数据无 id),audit 落 message_id=None,展示侧兼容。
|
||||
//
|
||||
// BUG-260624-03/P0 重构:短 lock 段读 current_message_id(纯读,无 await),clone 出来后续传参。
|
||||
// 短 lock 段读 current_message_id(纯读,无 await),clone 出来后续传参。
|
||||
// 整个 process_tool_calls 不再要求调用方持锁,内部所有 session.xxx 访问均短 lock 段,
|
||||
// 慢操作(execute_with_heartbeat/audit_tool_call/detect_retry_count/DB 查)全在锁外 await。
|
||||
// BUG-2026-07-19: current_message_id 读加 200ms timeout(防 session lock 竞争卡死致
|
||||
// current_message_id 读加 200ms timeout(防 session lock 竞争卡死致
|
||||
// process_tool_calls 进不去 emit AiToolCallStarted → 前端工具卡片不呈现 + 45s 看门狗超时)。
|
||||
// 超时用 None(溯源降级,非致命)。治本:消除 session lock 长持有(save clone 段已优化)。
|
||||
let current_message_id: Option<String> = match tokio::time::timeout(
|
||||
@@ -226,7 +226,7 @@ pub(crate) async fn process_tool_calls(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// ── F-260619-03 Phase B/C:文件工具路径授权预校验 ──
|
||||
// ── 文件工具路径授权预校验 ──
|
||||
// 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search)
|
||||
// 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单):
|
||||
// - 任一路径命中黑名单 → Denied:push 错误 tool_result + emit Completed,不挂起(Phase C)
|
||||
@@ -257,7 +257,7 @@ pub(crate) async fn process_tool_calls(
|
||||
// Phase C: Denied 路径 → 硬拒(push 错误 tool_result + emit Completed),不挂起 loop。
|
||||
// 工具返 Err 让 LLM 知路径被禁,自行调整;loop 继续下一轮(不暂停)。
|
||||
for (draft, reason) in path_denied {
|
||||
// BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
|
||||
// 拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
|
||||
let err_msg = serde_json::json!({
|
||||
"status": "rejected",
|
||||
"reason": "path_blacklist",
|
||||
@@ -294,7 +294,7 @@ pub(crate) async fn process_tool_calls(
|
||||
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
|
||||
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
|
||||
//
|
||||
// BUG-260624-03/P0 重构:detect_retry_count(DB 读)/emit/audit_tool_call(DB 写)均在锁外,
|
||||
// detect_retry_count(DB 读)/emit/audit_tool_call(DB 写)均在锁外,
|
||||
// 仅 push tool_result + pending_approvals.insert 两处纯写改短 lock 段。
|
||||
for (draft, args, req) in path_auth_pending {
|
||||
pending_count += 1;
|
||||
@@ -304,7 +304,7 @@ pub(crate) async fn process_tool_calls(
|
||||
.map(|d| d.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let path_str = req.raw_paths.first().cloned().unwrap_or_default();
|
||||
// 锁外:阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
||||
// 锁外:(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
||||
let retry_count = detect_retry_count(&audit_repo, &draft.id).await;
|
||||
if retry_count >= 1 {
|
||||
let skip_msg = format!(
|
||||
@@ -348,7 +348,7 @@ pub(crate) async fn process_tool_calls(
|
||||
arguments: args.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
recovered: false,
|
||||
// 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
|
||||
// 路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
|
||||
kind: ApprovalKind::Path(req),
|
||||
retry_count,
|
||||
created_at: Some(std::time::SystemTime::now()),
|
||||
@@ -381,7 +381,7 @@ pub(crate) async fn process_tool_calls(
|
||||
|
||||
// 分类:Low 收集并行执行,Med/High 立即进审批门控(push 占位 tool_result)
|
||||
//
|
||||
// F-260616-05:High risk 在进审批门前先查去重缓存(find_cached_high_risk_result)。
|
||||
// High risk 在进审批门前先查去重缓存(find_cached_high_risk_result)。
|
||||
// 若 LLM 重试同命令(同 tool_name + 同 args,键序无关),命中已落定的旧 tool_result,
|
||||
// 把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过 insert pending + 跳过审批,
|
||||
// 断「超时→重试→重新审批」循环。Med 不去重(去重易误伤),Low 无审批本就不进此分支。
|
||||
@@ -431,8 +431,8 @@ pub(crate) async fn process_tool_calls(
|
||||
).await;
|
||||
}
|
||||
|
||||
// AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,
|
||||
// BUG-260624-03/P0 重构后调用方不再持锁,本段所有 await 完全在锁外),
|
||||
// trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,
|
||||
// 调用方不再持锁,本段所有 await 完全在锁外),
|
||||
// push tool_result / audit 在 join_all 后串行回填(短 lock push + 锁外 audit)。对齐 Low risk 并行模式。
|
||||
// CR-51 修:原 inline .await execute 串行执行每个工具(阻塞期间锁被持有,run_command 慢命令
|
||||
// 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间。P0 重构后锁外并行,根本消除阻塞。
|
||||
@@ -503,13 +503,13 @@ pub(crate) async fn process_tool_calls(
|
||||
// push tool_result / audit 在 join_all 后串行回填(短 lock push + 锁外 audit,与 Med/High 占位拼接)。
|
||||
// join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort
|
||||
if !low_risk.is_empty() {
|
||||
// BUG-2026-07-07/P0-2:只读幂等工具去重缓存(治 LLM 死循环重调)。
|
||||
// 只读幂等工具去重缓存(治 LLM 死循环重调)。
|
||||
// 实测 9357c27c 会话:LLM 对 read_file(application.xml) 连调 17 次、search_files 连调 13 次,
|
||||
// 每次返回几乎相同结果却反复重调,是 prompt 64 万的直接元凶。此处先查会话内是否已对
|
||||
// 同参只读工具成功执行过,命中则直接回填缓存结果跳过真执行,断 LLM 失忆死循环。
|
||||
// 安全边界见 find_cached_readonly_result 文档(仅白名单只读工具 + 仅 completed 成功结果)。
|
||||
//
|
||||
// BUG-260624-03/P0 重构:find_cached_readonly_result 内部短 lock + 锁外 DB 查,本段不持锁。
|
||||
// find_cached_readonly_result 内部短 lock + 锁外 DB 查,本段不持锁。
|
||||
let mut low_risk_uncached: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::with_capacity(low_risk.len());
|
||||
for (draft, args, risk_level) in low_risk {
|
||||
let cached = find_cached_readonly_result(session_arc, conv_id, &audit_repo, &draft.name, &args).await;
|
||||
@@ -595,7 +595,7 @@ pub(crate) async fn process_tool_calls(
|
||||
Ok(c) => ("completed", c),
|
||||
Err(c) => ("failed", c),
|
||||
};
|
||||
// F-260619-04 P2(方案 B):create_idea source 消息级溯源补全(仅 source 空 + 有 message_id)。
|
||||
// source 消息级溯源补全(仅 source 空 + 有 message_id)。
|
||||
// 注:低风险路径目前 create_idea 不会进(Medium→pending),此处为防御/未来若调级别覆盖。
|
||||
// 仅 Ok(completed) 时补(失败 result 无 idea_id 意义);args 从 draft.args 反解(JSON 原样)。
|
||||
if status == "completed" {
|
||||
@@ -625,7 +625,7 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段3a:单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
|
||||
// 单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
|
||||
// 短 lock 段:读 pending_approvals 统计(纯读,无 await)
|
||||
let (path_count, risk_count, pending_approvals_len) = {
|
||||
let session = session_arc.lock().await;
|
||||
@@ -648,7 +648,7 @@ mod tests {
|
||||
|
||||
/// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。
|
||||
///
|
||||
/// F-260621:grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
|
||||
/// grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
|
||||
/// AiDirAuthRequired 申请(对齐 read_file),不像 search_files 被硬拒。
|
||||
/// 锁定此差异:grep 与 read_file 同款授权弹窗语义。
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! F-260619-03 Phase B/C: 路径授权预校验(文件工具路径提取 + 白/黑名单判定)。
|
||||
//! 路径授权预校验(文件工具路径提取 + 白/黑名单判定)。
|
||||
//!
|
||||
//! 第六批从 audit/mod.rs 抽离,行为零变更。包含:
|
||||
//! - `extract_file_tool_paths`:从工具参数中提取文件路径
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
use super::PathAuthRequest;
|
||||
|
||||
/// F-260619-03 Phase B: 提取文件工具的路径参数(用于路径授权预校验)。
|
||||
/// 提取文件工具的路径参数(用于路径授权预校验)。
|
||||
///
|
||||
/// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。
|
||||
/// - 单路径工具(read_file/write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep)
|
||||
@@ -37,7 +37,7 @@ pub(crate) fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value)
|
||||
}
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B/C: 路径授权预校验结果。
|
||||
/// 路径授权预校验结果。
|
||||
pub(crate) enum FileToolAuthOutcome {
|
||||
/// 全部已授权 → 走原 Low/Med/High 流程
|
||||
Authorized,
|
||||
@@ -47,7 +47,7 @@ pub(crate) enum FileToolAuthOutcome {
|
||||
Denied(String),
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B/C: 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。
|
||||
/// 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。
|
||||
///
|
||||
/// - 路径任一命中黑名单 → `Denied(reason)`:硬拒(工具返 Err tool_result,不挂起)
|
||||
/// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`:
|
||||
|
||||
@@ -36,7 +36,7 @@ pub(super) async fn resolve_project_label(db: &Arc<Database>, id: &str) -> Strin
|
||||
|
||||
/// 查任务可读标签:id → "「任务标题」(id=x)",对齐 resolve_project_label 三臂语义。
|
||||
///
|
||||
/// UX-260618-14:advance_task 审批卡的 id 是 task_id,原 build_approval_reason 把 "id"
|
||||
/// advance_task 审批卡的 id 是 task_id,原 build_approval_reason 把 "id"
|
||||
/// 统一走 resolve_project_label(查 projects 表),误把任务 id 当项目 id 解析,永远落到
|
||||
/// "项目已不存在"。本方法改查 tasks 表,与 resolve_project_label 同 Ok(None)/Err 分流。
|
||||
pub(super) async fn resolve_task_label(db: &Arc<Database>, id: &str) -> String {
|
||||
|
||||
@@ -44,7 +44,7 @@ pub(crate) async fn audit_tool_call(
|
||||
.insert(AiToolExecutionRecord {
|
||||
id: new_id(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
// F-260619-04 P1 消息级溯源:message_id 由调用方(process_tool_calls)从
|
||||
// P1 消息级溯源:message_id 由调用方(process_tool_calls)从
|
||||
// ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的
|
||||
// assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。
|
||||
// None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。
|
||||
|
||||
@@ -16,7 +16,7 @@ use super::risk_from_str;
|
||||
/// status=pending 的行(持久化真相源),此处读回重建内存态,使重启后待审批不丢。
|
||||
/// 前端经 ai_pending_tool_calls 查询 + switchConversation 恢复 toolCard 的 pending_approval 态。
|
||||
///
|
||||
/// **F-260616-09 B 批8 多 conv 适配(设计 §3 batch8 + §5.2)**:DB pending 审批按
|
||||
/// **多 conv 适配**:DB pending 审批按
|
||||
/// `conversation_id` 分配到各 conv 的 per_conv state——对每个含 pending 审批的 conv
|
||||
/// **惰性建 `PerConvState`**(不依赖 `active_conversation_id` 单值),使后续 ai_approve →
|
||||
/// try_continue_agent_loop 的 `conv_read(conv_id)` 命中各自 per_conv(各归各,不串)。
|
||||
@@ -69,19 +69,19 @@ pub async fn restore_pending_approvals(state: &AppState) {
|
||||
arguments: args,
|
||||
conversation_id: rec.conversation_id,
|
||||
recovered: true,
|
||||
// 阶段3a 单真相源合并:恢复的审批一律 kind=Risk(diff=None,与原顶层 diff 字段同语义)。
|
||||
// 单真相源合并:恢复的审批一律 kind=Risk(diff=None,与原顶层 diff 字段同语义)。
|
||||
//
|
||||
// **path 审批不恢复的决策(语义保留)**:路径授权挂起是会话级状态,重启后 session
|
||||
// 重建,无法恢复挂起语义;且 path 的"always"决策已写入持久白名单(Settings KV),
|
||||
// 重启后白名单仍生效(文件工具路径预校验会直接 Authorized,不再挂起)。故 path 审批
|
||||
// 无需恢复 —— 恢复 risk 即可覆盖所有需人工决策的积压。
|
||||
//
|
||||
// AE-2025-03: 重启恢复的审批不重读旧文件——审批可能跨重启,
|
||||
// 重启恢复的审批不重读旧文件——审批可能跨重启,
|
||||
// 期间文件可能已被外部改动,重读生成 diff 反映的不是当初决策时的状态,
|
||||
// 且恢复路径在 session.lock 内做 async IO 复杂度高,预览价值低。
|
||||
// 前端见 diff=None 时回退显新 content。
|
||||
kind: ApprovalKind::Risk { diff: None },
|
||||
// 阶段4:重启恢复的审批 retry_count=0(恢复语义即"待用户首次决策",非重试)。
|
||||
// 重启恢复的审批 retry_count=0(恢复语义即"待用户首次决策",非重试)。
|
||||
// 即便审计表已有 pending 记录,恢复后用户审批执行属首次正常执行,不断路。
|
||||
retry_count: 0,
|
||||
// 恢复审批 created_at=重启时刻(无原挂起时间记录,按当前系统时间计)。
|
||||
@@ -98,12 +98,12 @@ pub async fn restore_pending_approvals(state: &AppState) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_f09_batch8_restore {
|
||||
mod tests_multi_conv_restore {
|
||||
use super::super::{ApprovalKind, PendingApproval};
|
||||
use super::*;
|
||||
use crate::commands::ai::AiSession;
|
||||
|
||||
/// F-260616-09 B 批8(设计 §3 batch8 + §5.2):验证 restore_pending_approvals 的多 conv 分配不变量。
|
||||
/// 验证 restore_pending_approvals 的多 conv 分配不变量。
|
||||
///
|
||||
/// restore_pending_approvals 受限于 AppState(需 DB),无法直接单测。但其核心分配逻辑
|
||||
/// 「对每个 conversation_id=Some 的恢复审批,惰性建/复用对应 conv 的 PerConvState」依赖
|
||||
@@ -129,7 +129,7 @@ mod tests_f09_batch8_restore {
|
||||
let _ = session.conv(cid); // 惰性建
|
||||
}
|
||||
}
|
||||
// 阶段3a 单真相源合并:恢复的 pending 全部进 pending_approvals,kind=Risk。
|
||||
// 单真相源合并:恢复的 pending 全部进 pending_approvals,kind=Risk。
|
||||
session.pending_approvals.insert(
|
||||
tool_call_id.clone(),
|
||||
PendingApproval {
|
||||
@@ -139,7 +139,7 @@ mod tests_f09_batch8_restore {
|
||||
conversation_id: conversation_id.clone(),
|
||||
recovered: true,
|
||||
kind: ApprovalKind::Risk { diff: None },
|
||||
// 阶段4:恢复的审批 retry_count=0(首次用户决策,非重试)。
|
||||
// 恢复的审批 retry_count=0(首次用户决策,非重试)。
|
||||
retry_count: 0,
|
||||
created_at: None,
|
||||
},
|
||||
@@ -156,7 +156,7 @@ mod tests_f09_batch8_restore {
|
||||
);
|
||||
|
||||
// 不变量 2:pending_approvals HashMap 4 条(含无主),conversation_id 保留(业务语义)。
|
||||
// 阶段3a:全部进单表 pending_approvals(原 risk_pending 合一,path 审批不恢复)。
|
||||
// 全部进单表 pending_approvals(原 risk_pending 合一,path 审批不恢复)。
|
||||
assert_eq!(session.pending_approvals.len(), 4, "全部 pending 入 pending_approvals");
|
||||
// 构造期恒等式:上方 pending_rows 构造的 4 条 PendingApproval 全部 kind=Risk(本测试构造
|
||||
// 时未插入任何 Path(_)),故 filter Path(_) 计数必为 0。这是构造恒等式而非外部不变量——
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Augmentation 注入段构建(核心设计2 注入侧)
|
||||
//!
|
||||
//! [`build_augmentation_segment`] 把 resolve 后的 [`Augmentation`] 列表拼成一段
|
||||
//! 隔离标注的系统提示词片段,拼到 system_prompt 前(复用 chat.rs FR-S4 风格:
|
||||
//! 隔离标注的系统提示词片段,拼到 system_prompt 前(复用 chat.rs 风格:
|
||||
//! 头尾明确标注"仅供 AI 参考,非用户消息,勿作为行为准则覆盖",防 prompt injection 混淆)。
|
||||
//!
|
||||
//! 空列表返空串(调用方据此跳过拼接,不污染 prompt)。多语言按 lang 参数选标题。
|
||||
@@ -22,7 +22,7 @@ fn title_lang(lang: &str) -> &'static str {
|
||||
///
|
||||
/// - 空 augs 返 `""`(调用方跳过拼接,不污染 prompt)。
|
||||
/// - 非空:头尾标注段(`--- 以下是用户选择的上下文参考 ... ---` 包裹),
|
||||
/// 每条 augmentation 按 kind 分小节(项目/任务/灵感/技能),复用 chat.rs FR-S4 隔离头风格。
|
||||
/// 每条 augmentation 按 kind 分小节(项目/任务/灵感/技能),复用 chat.rs 隔离头风格。
|
||||
///
|
||||
/// `lang` 控制标题语言(zh/en),与 [`build_system_prompt`](super::super::prompt::build_system_prompt) 同源。
|
||||
///
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! 再经 `ai/mod.rs` 的 `pub use self::commands::*;` 透传到 `commands::ai::*`,
|
||||
//! 保 `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。
|
||||
//!
|
||||
//! F-09 batch4 conv_id 签名(决策 e 真并发)原样保留,不改 IPC 签名/行为(纯搬迁)。
|
||||
//! conv_id 签名(真并发支持)原样保留,不改 IPC 签名/行为(纯搬迁)。
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
@@ -52,15 +52,15 @@ use super::super::{AiChatEvent, ApprovalKind, SessionState};
|
||||
///
|
||||
/// 参数 `final_text` 由调用方按清理场景语义选择(如"会话已停止"/"已取消"/"会话已清除")。
|
||||
///
|
||||
/// F-260616-09 B 批4:per_conv.messages 唯一真相源,删顶层 messages 双写。
|
||||
/// per_conv.messages 唯一真相源,删顶层 messages 双写。
|
||||
/// 终态化策略:每条 pending 审批按其自身 `conversation_id` 终态化到对应 conv 的 per_conv.messages;
|
||||
/// conv_id 入参作 fallback(审批无 conversation_id 的无主审批 R-9 异常数据,终态化到入参 conv,
|
||||
/// 入参也空则跳过——审计仍记,占位不残留内存因 pending 即将被 clear/retain)。
|
||||
pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSession, conv_id: &str, final_text: &str) {
|
||||
// SW-260618-02: 先 clone pending 的 tool_call_id(借用在此结束),再可变借 messages。
|
||||
// 必须整体借 &mut session 在函数体内做 disjoint field borrow —— 调用方若分别传
|
||||
// &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502(2026-06-18 主代修)。
|
||||
// 阶段3a 单真相源合并:两类挂起(path + risk)合一进 pending_approvals,占位都需终态化。
|
||||
// &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502。
|
||||
// 单真相源合并:两类挂起(path + risk)合一进 pending_approvals,占位都需终态化。
|
||||
let entries: Vec<(String, Option<String>)> = session
|
||||
.pending_approvals
|
||||
.values()
|
||||
@@ -249,22 +249,22 @@ pub async fn ai_regenerate(
|
||||
let provider_config = super::super::prompt::get_active_provider(&state).await?;
|
||||
|
||||
// 原子占用 generating + 弹出末尾 AI 回复(保留 user 消息)
|
||||
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
|
||||
// conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
|
||||
// (真并发下后台 conv 也应可重新生成);per_conv 唯一真相源,删顶层双写。
|
||||
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,不占 session lock)。
|
||||
// ConvState 读侧切无锁 conv_states(零锁竞争,不占 session lock)。
|
||||
if !state.conv_states.can_accept_request(&conversation_id) {
|
||||
return Err("AI 正在生成中,请等待完成".to_string());
|
||||
}
|
||||
{
|
||||
let mut session = state.ai_session.lock().await;
|
||||
let conv = session.conv(&conversation_id);
|
||||
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口 ConvState→Generating
|
||||
// generating bool 已退役,生成态由 run_agentic_loop 入口 ConvState→Generating
|
||||
// 迁移设置(此处不再手动赋值)。stop_flag/iteration/model_override 仍是 per_conv 独立字段。
|
||||
conv.stop_flag.store(false, Ordering::SeqCst);
|
||||
conv.agent_language = language.clone();
|
||||
// F-260616-11: 重生成 = 新生命周期起点,iteration 从头计数。
|
||||
// 重生成 = 新生命周期起点,iteration 从头计数。
|
||||
conv.iteration_used = 0;
|
||||
// F-01 阶段6: 记录用户指定模型 override(主对话专用,兜底见 run_agentic_loop)。
|
||||
// 记录用户指定模型 override(主对话专用,兜底见 run_agentic_loop)。
|
||||
conv.model_override = model_override.clone();
|
||||
let popped = conv.messages.pop_last_assistant_round();
|
||||
if !popped {
|
||||
@@ -278,7 +278,7 @@ pub async fn ai_regenerate(
|
||||
.map(|m| matches!(m.role, df_ai::provider::MessageRole::User))
|
||||
.unwrap_or(false);
|
||||
if !last_is_user {
|
||||
// 批3 收口:generating bool 已退役,此处仅 Err 返回(ConvState 仍 Idle,入口迁移
|
||||
// generating bool 已退役,此处仅 Err 返回(ConvState 仍 Idle,入口迁移
|
||||
// 在 run_agentic_loop 内执行,本路径尚未进入 loop,无需手动复位)。
|
||||
return Err("没有可重新生成的回复".to_string());
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ fn env_profile_line() -> String {
|
||||
fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str) {
|
||||
match lang {
|
||||
"en" => (
|
||||
"You are DevFlow's AI assistant. You help users manage projects, tasks, ideas, and workflows.\n\
|
||||
"You are DevFlow's AI assistant. You can manage projects, tasks, ideas, and workflows, as well as analyze code, read/write files, and execute commands.\n\
|
||||
Please respond in English.\n\n\
|
||||
## Capabilities\n\
|
||||
You can perform the following actions via tool calls:\n\
|
||||
@@ -88,7 +88,7 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
|
||||
"\n## Current Tasks\n",
|
||||
),
|
||||
_ => (
|
||||
"你是 DevFlow 的 AI 助手。你帮助用户管理项目、任务、灵感和工作流。\n\
|
||||
"你是 DevFlow 桌面应用的 AI 助手。你可以操作项目/任务/灵感/工作流,也可分析代码、读写文件、执行命令。\n\
|
||||
必须使用简体中文回复,禁止使用繁体中文字符。\n\n\
|
||||
## 当前能力\n\
|
||||
你可以通过工具调用执行以下操作:\n\
|
||||
@@ -192,6 +192,18 @@ pub(crate) async fn build_system_prompt_with_excluded(
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义提示词(设置中配置,追加到系统 prompt 末尾)
|
||||
if let Ok(Some(custom)) = state.settings.get("custom_prompt").await {
|
||||
if !custom.is_empty() {
|
||||
let clean = custom.trim().trim_matches('"');
|
||||
if !clean.is_empty() {
|
||||
prompt.push_str("\n## 自定义指令\n");
|
||||
prompt.push_str(clean);
|
||||
prompt.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prompt
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,31 @@ pub(crate) enum StreamResult {
|
||||
InitFailed { retryable: bool, error: String },
|
||||
}
|
||||
|
||||
/// 将 chunk 携带的 tool_calls delta 累加到累积表。
|
||||
///
|
||||
/// 每个 `ToolCallDelta` 按 `index` 归位到同一个 `ToolCallDraft`:
|
||||
/// - `id` 覆盖(后到的 id 视为权威,匹配 OpenAI/Anthropic 协议行为)
|
||||
/// - `function_name` / `function_arguments` 增量拼接(流式分片到达)
|
||||
///
|
||||
/// 抽取自 stream_llm 的 chunk match arm(原最深 7 层嵌套点),纯状态累加无 emit/return 副作用。
|
||||
fn accumulate_tool_calls(
|
||||
tc_deltas: &[df_ai::provider::ToolCallDelta],
|
||||
tool_calls_acc: &mut HashMap<u32, ToolCallDraft>,
|
||||
) {
|
||||
for tc_delta in tc_deltas {
|
||||
let draft = tool_calls_acc.entry(tc_delta.index).or_default();
|
||||
if let Some(id) = &tc_delta.id {
|
||||
draft.id = id.clone();
|
||||
}
|
||||
if let Some(name) = &tc_delta.function_name {
|
||||
draft.name.push_str(name);
|
||||
}
|
||||
if let Some(args) = &tc_delta.function_arguments {
|
||||
draft.args.push_str(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式接收 LLM 响应。
|
||||
///
|
||||
/// 三类异常处理(返回 StreamResult 显式区分出口):
|
||||
@@ -339,12 +364,7 @@ pub(crate) async fn stream_llm(
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
if let Some(tc_deltas) = &chunk.tool_calls {
|
||||
for tc_delta in tc_deltas {
|
||||
let draft = tool_calls_acc.entry(tc_delta.index).or_default();
|
||||
if let Some(id) = &tc_delta.id { draft.id = id.clone(); }
|
||||
if let Some(name) = &tc_delta.function_name { draft.name.push_str(name); }
|
||||
if let Some(args) = &tc_delta.function_arguments { draft.args.push_str(args); }
|
||||
}
|
||||
accumulate_tool_calls(tc_deltas, &mut tool_calls_acc);
|
||||
}
|
||||
if let Some(u) = &chunk.usage {
|
||||
final_usage = Some(u.clone());
|
||||
|
||||
@@ -34,6 +34,53 @@ struct WorkflowEventPayload {
|
||||
event: WorkflowEvent,
|
||||
}
|
||||
|
||||
/// Lagged 兜底:查 DB 终态,命中返回 `(要补发的事件, status)`;未命中返回 None。
|
||||
///
|
||||
/// broadcast `Lagged` 不暴露被丢事件类型,关键终态事件(WorkflowCompleted/Failed)可能已丢,
|
||||
/// forward 循环会永久等不到。达阈值时查 DB 终态补发。三种"未命中"(仍 running / 无记录 /
|
||||
/// 查询失败)统一返回 None 并各自 warn,主循环据此重置 `lagged_total` 继续等。
|
||||
async fn probe_lagged_terminal(
|
||||
db: &df_storage::db::Database,
|
||||
exec_id: &ExecutionId,
|
||||
) -> Option<(WorkflowEvent, String)> {
|
||||
let record = match WorkflowRepo::new(db).get_by_id(exec_id).await {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
execution_id = %exec_id,
|
||||
"Lagged 兜底查询未找到执行记录,继续等待事件"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
execution_id = %exec_id,
|
||||
error = %e,
|
||||
"Lagged 兜底查询 DB 失败,继续等待事件"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let event = match record.status.as_str() {
|
||||
"completed" => WorkflowEvent::WorkflowCompleted {
|
||||
execution_id: exec_id.clone(),
|
||||
total_duration_ms: 0,
|
||||
},
|
||||
"failed" => WorkflowEvent::WorkflowFailed {
|
||||
execution_id: exec_id.clone(),
|
||||
error: "工作流执行失败(Lagged 兜底补发,详情见 DB)".to_string(),
|
||||
failed_node: String::new(),
|
||||
},
|
||||
"cancelled" => WorkflowEvent::WorkflowFailed {
|
||||
execution_id: exec_id.clone(),
|
||||
error: "工作流被取消(Lagged 兜底补发)".to_string(),
|
||||
failed_node: String::new(),
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
Some((event, record.status))
|
||||
}
|
||||
|
||||
/// 触发工作流执行(核心命令)
|
||||
///
|
||||
/// 流程:build_dag 校验 → 写入执行记录(status=running) → 后台异步执行 →
|
||||
@@ -183,63 +230,29 @@ pub async fn run_workflow_inner(
|
||||
if lagged_total < LAGGED_PROBE_THRESHOLD {
|
||||
continue;
|
||||
}
|
||||
// 累计/单次达阈值:查 DB 终态兜底
|
||||
let workflows = WorkflowRepo::new(&forward_db);
|
||||
match workflows.get_by_id(&forward_exec_id).await {
|
||||
Ok(Some(record)) => {
|
||||
let terminal = match record.status.as_str() {
|
||||
"completed" => Some(WorkflowEvent::WorkflowCompleted {
|
||||
execution_id: forward_exec_id.clone(),
|
||||
total_duration_ms: 0,
|
||||
}),
|
||||
"failed" => Some(WorkflowEvent::WorkflowFailed {
|
||||
execution_id: forward_exec_id.clone(),
|
||||
error: "工作流执行失败(Lagged 兜底补发,详情见 DB)"
|
||||
.to_string(),
|
||||
failed_node: String::new(),
|
||||
}),
|
||||
"cancelled" => Some(WorkflowEvent::WorkflowFailed {
|
||||
execution_id: forward_exec_id.clone(),
|
||||
error: "工作流被取消(Lagged 兜底补发)".to_string(),
|
||||
failed_node: String::new(),
|
||||
}),
|
||||
_ => None,
|
||||
// 累计/单次达阈值:查 DB 终态兜底(probe_lagged_terminal 收口三种未命中情况)
|
||||
match probe_lagged_terminal(&forward_db, &forward_exec_id).await {
|
||||
Some((synth_event, status)) => {
|
||||
let payload = WorkflowEventPayload {
|
||||
execution_id: forward_exec_id.clone(),
|
||||
event: synth_event.clone(),
|
||||
};
|
||||
if let Some(synth_event) = terminal {
|
||||
let payload = WorkflowEventPayload {
|
||||
execution_id: forward_exec_id.clone(),
|
||||
event: synth_event.clone(),
|
||||
};
|
||||
if let Err(e) = forward_app.emit("workflow-event", &payload) {
|
||||
tracing::warn!("Lagged 终态兜底事件转发失败: {}", e);
|
||||
}
|
||||
tracing::info!(
|
||||
execution_id = %forward_exec_id,
|
||||
status = %record.status,
|
||||
"Lagged 兜底命中终态,补发 {} 并退出 forward",
|
||||
match synth_event {
|
||||
WorkflowEvent::WorkflowCompleted { .. } => "WorkflowCompleted",
|
||||
_ => "WorkflowFailed",
|
||||
}
|
||||
);
|
||||
break;
|
||||
if let Err(e) = forward_app.emit("workflow-event", &payload) {
|
||||
tracing::warn!("Lagged 终态兜底事件转发失败: {}", e);
|
||||
}
|
||||
// DB 仍非终态(running),重置累计计数继续等待后续事件
|
||||
lagged_total = 0;
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
tracing::info!(
|
||||
execution_id = %forward_exec_id,
|
||||
"Lagged 兜底查询未找到执行记录,继续等待事件"
|
||||
status = %status,
|
||||
"Lagged 兜底命中终态,补发 {} 并退出 forward",
|
||||
match synth_event {
|
||||
WorkflowEvent::WorkflowCompleted { .. } => "WorkflowCompleted",
|
||||
_ => "WorkflowFailed",
|
||||
}
|
||||
);
|
||||
lagged_total = 0;
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
execution_id = %forward_exec_id,
|
||||
error = %e,
|
||||
"Lagged 兜底查询 DB 失败,继续等待事件"
|
||||
);
|
||||
None => {
|
||||
// 未命中(仍 running / 无记录 / 查询失败):重置累计计数继续等待
|
||||
lagged_total = 0;
|
||||
}
|
||||
}
|
||||
|
||||
+36
-18
@@ -292,20 +292,14 @@ pub fn run() {
|
||||
Err(e) => tracing::warn!("[tunnel] 连接 relay 失败(非阻断,supervisor 将重试): {}", e),
|
||||
}
|
||||
|
||||
// ── supervisor:断线自动重连(根治 device 离线) ──
|
||||
// df-tunnel 无内置 auto-reconnect(tunnel.rs:332 注释留外层 supervisor)。
|
||||
// 桌面端进程重启 / 网络断 / relay 重启 → device 离线 → miniapp 命令 delivered=0
|
||||
// → 点发无响应。supervisor 轮询 is_connected,断开则用保存参数指数退避重连,
|
||||
// 复用同一 on_command 回调(逻辑不变)。task 永驻(supervisor 生命周期 = 应用生命周期)。
|
||||
// supervisor:轮询 is_connected,断开则重连
|
||||
// 连接被拒(relay 未部署)→300s+INFO;其他错误→2s-60s WARN
|
||||
let mut backoff = std::time::Duration::from_secs(2);
|
||||
let mut last_was_refused = false;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
if !state.tunnel.is_connected() {
|
||||
tracing::info!(
|
||||
"[tunnel-supervisor] 检测到断开,尝试重连 url={} device_id={}",
|
||||
relay_url,
|
||||
device_id
|
||||
);
|
||||
tracing::info!("[tunnel-supervisor] 断线重连 url={relay_url} device_id={device_id}");
|
||||
match state
|
||||
.tunnel
|
||||
.connect(&relay_url, &device_id, &token, on_command.clone())
|
||||
@@ -314,24 +308,39 @@ pub fn run() {
|
||||
Ok(()) => {
|
||||
tracing::info!("[tunnel-supervisor] 重连成功,退避重置");
|
||||
backoff = std::time::Duration::from_secs(2);
|
||||
last_was_refused = false;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
backoff_secs = backoff.as_secs(),
|
||||
"[tunnel-supervisor] 重连失败,退避后重试"
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
// 指数退避上限 60s,防网络长故障时高频重连打 relay
|
||||
backoff = (backoff * 2).min(std::time::Duration::from_secs(60));
|
||||
let refused = is_connection_refused(&e);
|
||||
if refused {
|
||||
// relay 未运行:INFO + 300s 退避,不刷 WARN
|
||||
if !last_was_refused {
|
||||
tracing::info!("[tunnel-supervisor] relay 未运行({e}),300s 后轻量探测");
|
||||
last_was_refused = true;
|
||||
}
|
||||
// 300s 退避(避免空转刷屏,relay 未运行时一直保持此间隔)
|
||||
tokio::time::sleep(std::time::Duration::from_secs(300)).await;
|
||||
} else {
|
||||
tracing::warn!("[tunnel-supervisor] 重连失败 {e},退避 {backoff_secs}s", backoff_secs = backoff.as_secs());
|
||||
tokio::time::sleep(backoff).await;
|
||||
// 指数退避上限 60s,防网络长故障时高频重连打 relay
|
||||
backoff = (backoff * 2).min(std::time::Duration::from_secs(60));
|
||||
last_was_refused = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if last_was_refused {
|
||||
last_was_refused = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
// 辅助函数 is_connection_refused 在文件尾部
|
||||
// 判断 TunnelError 是否为连接被拒(relay 未运行)。
|
||||
// 匹配 WS connect_async 产生的 IO error:ECONNREFUSED(Win:10061,Unix:111)。
|
||||
// 被拒 → relay 未部署,用长退避避免刷屏;其他错误(超时/DNS/网络瞬断)走指数退避。
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// 项目
|
||||
commands::project::list_projects,
|
||||
@@ -500,3 +509,12 @@ pub fn run() {
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
/// 判断 TunnelError 是否为连接被拒(匹配 ECONNREFUSED 各平台表述)
|
||||
fn is_connection_refused(e: &df_tunnel::TunnelError) -> bool {
|
||||
let msg = e.to_string().to_lowercase();
|
||||
msg.contains("refused")
|
||||
|| msg.contains("10061")
|
||||
|| msg.contains("积极拒绝")
|
||||
|| msg.contains("actively refused")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user