重构: 巨函数拆分 + 清理历史标记注释 + custom_prompt/停止按钮/tunnel 改进

This commit is contained in:
lxy
2026-07-31 21:36:12 +08:00
parent 365af554da
commit bd9031d35d
73 changed files with 1421 additions and 1064 deletions
@@ -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)
}
+1 -1
View File
@@ -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
+15 -15
View File
@@ -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 即时反馈)。
+12 -12
View File
@@ -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 };
+3 -3
View File
@@ -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 自身会校验
+1 -1
View File
@@ -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`
+26 -26
View File
@@ -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_MS130s)——bash 跑 cargo/测试、read 大文件、
/// 全盘 search 等开发长命令——前端 watchdog 误判断流,抛"工具已执行完成后续中断"误报
/// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。
@@ -137,7 +137,7 @@ async fn execute_with_heartbeat(
});
// RAIIexecute 无论 Ok/Err/panicunwind),_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.idstream_recv 按 content_block index 分桶,
// 治本兜底:LLM 异常复用同 tool_use.idstream_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.messagesaudit/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 + 黑名单):
// - 任一路径命中黑名单 → Deniedpush 错误 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-05High 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-260621grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
/// grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
/// AiDirAuthRequired 申请(对齐 read_file),不像 search_files 被硬拒。
/// 锁定此差异:grep 与 read_file 同款授权弹窗语义。
#[test]
+4 -4
View File
@@ -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`
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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),展示侧兼容。
+9 -9
View File
@@ -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) 同源。
///
+10 -10
View File
@@ -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());
}
+14 -2
View File
@@ -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
}
+26 -6
View File
@@ -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());
+65 -52
View File
@@ -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
View File
@@ -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")
}