重构: 巨函数拆分 + 清理历史标记注释 + 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
+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。这是构造恒等式而非外部不变量——