新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端: - 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展 - 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通 - 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦 - AI planner/plan_hint/intent:aichat B 路线并行多轮基础 - patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环 - 压缩超时兜底(F-15 卡死根治) - F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本 - 知识注入 DRY/skills/audit 扩展 清理: - aichat 技术债(误报 allow/死导入/过时注释 30 项) - URGENT.md 删除(11 项加急全解决/迁 todo) - 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
This commit is contained in:
@@ -9,13 +9,40 @@ use df_storage::crud::AiToolExecutionRepo;
|
||||
|
||||
use super::super::AiSession;
|
||||
|
||||
/// Med/High 工具挂起审批时回填的占位 tool_result 内容。
|
||||
/// Med/High 工具挂起审批时回填的占位 tool_result 内容(基础文本,不含 tc_id 标记)。
|
||||
///
|
||||
/// 单一常量避免「写入处」(process_tool_calls) 与「去重排查处」
|
||||
/// (find_cached_high_risk_result) 各持一份字面量致耦合——若两者漂移,
|
||||
/// 去重会把 pending 占位误判为已落定结果命中缓存,污染 LLM 上下文。
|
||||
///
|
||||
/// **阶段2(占位配对完整性)**:实际写入 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 检测)
|
||||
/// 据此把占位豁免保留(不丢),出口断言 `assert_placeholder_pairing` 据此补 TOOL_MISSING_PREFIX
|
||||
/// 占位头自愈,使占位 result 与(可能被裁掉的)tool_call 头闭合配对,防 provider 400 orphan。
|
||||
///
|
||||
/// 标记格式:`{PENDING_APPROVAL_PLACEHOLDER}{PENDING_MARKER_PREFIX}{tc_id}`,
|
||||
/// 如 "需要用户审批,等待确认__PENDING__:call_abc123"。tc_id 为空时退回基础文本(老格式,向前兼容)。
|
||||
pub(crate) fn pending_placeholder_for(tc_id: &str) -> String {
|
||||
if tc_id.is_empty() {
|
||||
return PENDING_APPROVAL_PLACEHOLDER.to_string();
|
||||
}
|
||||
format!("{}{}{}", PENDING_APPROVAL_PLACEHOLDER, df_ai::context_helpers::PENDING_MARKER_PREFIX, tc_id)
|
||||
}
|
||||
|
||||
/// 判定 tool_result content 是否为审批挂起占位(含基础文本或带 __PENDING__ 标记)。
|
||||
///
|
||||
/// 替代原裸 `msg.content == PENDING_APPROVAL_PLACEHOLDER` 精确匹配(阶段2 占位带 tc_id 标记后
|
||||
/// 不再精确等于基础文本,需用子串/标记匹配)。兼容老占位(纯文本)与新占位(带标记)。
|
||||
pub(crate) fn is_pending_placeholder(content: &str) -> bool {
|
||||
df_ai::context_helpers::is_pending_placeholder(content)
|
||||
}
|
||||
|
||||
/// F-260616-05:高危工具去重(根治 run_command 超时→重试→重新审批循环)。
|
||||
///
|
||||
/// ⚠ 性能注记(CR-260618-11#5):本函数在 session lock 持有期间对每个 high risk 工具
|
||||
@@ -41,7 +68,7 @@ pub(crate) const PENDING_APPROVAL_PLACEHOLDER: &str = "需要用户审批,等
|
||||
/// `session` 只读扫描 messages(不写),调用方据返回值决定是否跳过 insert pending。
|
||||
///
|
||||
/// F-260616-09 B 批2:经`conv_id` 索引 per_conv.messages(顶层 messages 批2 后是死字段)。
|
||||
/// conv_id 来源:process_tool_calls 入参 → 由 agentic.rs run_agentic_loop 入参透传。
|
||||
/// conv_id 来源:process_tool_calls 入参 → 由 agentic/mod.rs run_agentic_loop 入参透传。
|
||||
pub(crate) async fn find_cached_high_risk_result(
|
||||
session: &AiSession,
|
||||
conv_id: &str,
|
||||
@@ -98,8 +125,12 @@ pub(crate) async fn find_cached_high_risk_result(
|
||||
if msg.tool_call_id.as_deref() != Some(old_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
// 命中旧 tool_result:排除 pending 占位(内容固定为 PENDING_APPROVAL_PLACEHOLDER)
|
||||
if msg.content == PENDING_APPROVAL_PLACEHOLDER {
|
||||
// 命中旧 tool_result:排除 pending 占位(基础文本或带 __PENDING__:tc_id 标记)
|
||||
// 阶段2:占位带标记后不再精确等于基础文本,改用 is_pending_placeholder 匹配。
|
||||
// 加固(子串误伤):`__PENDING__` 标记是 audit/cache.rs 占位模板独占信号(权威判定);
|
||||
// 老占位分支已收紧为精确全文等值(非 starts_with 前缀),杜绝用户真实 tool_result 内容
|
||||
// 恰以"需要用户审批..."开头被误判占位致去重误吞(详见 context_helpers::is_pending_placeholder)。
|
||||
if is_pending_placeholder(&msg.content) {
|
||||
return None;
|
||||
}
|
||||
// SW-260618-16: 查审计表拿缓存来源真实 status(completed/rejected/failed),透传给
|
||||
|
||||
@@ -163,6 +163,7 @@ mod idea_source_test_helpers {
|
||||
promoted_to: None,
|
||||
ai_analysis: None,
|
||||
scores: None,
|
||||
related_ids: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::state::AppState;
|
||||
|
||||
use crate::commands::err_str;
|
||||
|
||||
use super::{AiChatEvent, AiSession, PathAuthRequest, PendingApproval, ToolCallDraft};
|
||||
use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft};
|
||||
// tool_registry::generate_diff 经 audit/diff.rs 直接 use(build_write_file_diff 内调用),
|
||||
// 本文件不再裸名用 generate_diff,故不再在此 use(避免 unused import warning)。
|
||||
|
||||
@@ -116,9 +116,10 @@ pub(crate) use finalize::{audit_finalize, audit_tool_call};
|
||||
|
||||
// cache(audit/cache.rs):F-260616-05 高危工具去重缓存。
|
||||
// 第三批从本文件抽离,行为零变更。pub(super) use 供本文件 process_tool_calls 裸名调用
|
||||
// (find_cached_high_risk_result),PENDING_APPROVAL_PLACEHOLDER 供 process_tool_calls 占位回填共享。
|
||||
// (find_cached_high_risk_result)。阶段2:prior placeholder 现经 pending_placeholder_for
|
||||
// (内嵌 __PENDING__:tc_id 标记)生成,PENDING_APPROVAL_PLACEHOLDER 基础常量留在 cache.rs 内部。
|
||||
mod cache;
|
||||
pub(super) use cache::{find_cached_high_risk_result, PENDING_APPROVAL_PLACEHOLDER};
|
||||
pub(super) use cache::{find_cached_high_risk_result, pending_placeholder_for};
|
||||
|
||||
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
|
||||
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
|
||||
@@ -135,7 +136,7 @@ pub(crate) use idea_source::maybe_fill_idea_source;
|
||||
/// 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)
|
||||
/// - 单路径工具(read_file/write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep)
|
||||
/// 取 args["path"]
|
||||
/// - rename_file 双路径取 args["from"] + args["to"](两个都需授权)
|
||||
/// - run_command 不返回(它只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批)
|
||||
@@ -152,7 +153,7 @@ fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value) -> Vec<Str
|
||||
v
|
||||
}
|
||||
"read_file" | "write_file" | "list_directory" | "patch_file" | "file_info"
|
||||
| "append_file" | "delete_file" | "search_files" => {
|
||||
| "append_file" | "delete_file" | "search_files" | "grep" => {
|
||||
args.get("path").and_then(|x| x.as_str()).map(|s| vec![s.to_string()]).unwrap_or_default()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
@@ -182,21 +183,66 @@ fn check_file_tool_auth(
|
||||
// 非文件工具或无路径参数(如缺 path 的异常调用)→ 放行,走原流程(handler 内会因缺参 Err)
|
||||
return FileToolAuthOutcome::Authorized;
|
||||
}
|
||||
let mut pending_dir: Option<std::path::PathBuf> = None;
|
||||
// L1 补丁(rename_file 双路径漏校):收集**所有**未授权父目录(去重),
|
||||
// 不只首个。rename_file 的 from/to 若分属不同未授权目录,两个 dir 都需挂起授权。
|
||||
let mut pending_dirs: Vec<std::path::PathBuf> = Vec::new();
|
||||
for p in &paths {
|
||||
match check_path_authorization(p, allowed) {
|
||||
PathAuthDecision::Denied { reason } => return FileToolAuthOutcome::Denied(reason),
|
||||
PathAuthDecision::NeedsAuthorization { dir } => {
|
||||
if pending_dir.is_none() {
|
||||
pending_dir = Some(dir);
|
||||
if !pending_dirs.contains(&dir) {
|
||||
pending_dirs.push(dir);
|
||||
}
|
||||
}
|
||||
PathAuthDecision::Authorized => {}
|
||||
}
|
||||
}
|
||||
match pending_dir {
|
||||
Some(dir) => FileToolAuthOutcome::NeedsAuth(PathAuthRequest { dir, raw_paths: paths }),
|
||||
None => FileToolAuthOutcome::Authorized,
|
||||
if pending_dirs.is_empty() {
|
||||
FileToolAuthOutcome::Authorized
|
||||
} else if tool_name == "search_files" {
|
||||
// 核心设计5:search_files 兜底 — 盲搜语义应拒不应询。
|
||||
// search_files 仅在已授权目录可用;用户已通过 @项目 提供项目上下文,无需搜索文件系统。
|
||||
// (黑名单已在上方 for 循环内短路返 Denied,走到此处均为 NeedsAuthorization 场景。)
|
||||
// 其他文件工具(read_file/write_file/list_directory/patch_file/file_info/
|
||||
// append_file/delete_file/rename_file)保持原 NeedsAuth 弹窗逻辑完全不变。
|
||||
FileToolAuthOutcome::Denied(
|
||||
"search_files 仅在已授权目录可用;请用 @[项目] 引用或提供绝对路径,不要盲搜文件系统。".to_string(),
|
||||
)
|
||||
} else {
|
||||
FileToolAuthOutcome::NeedsAuth(PathAuthRequest { dirs: pending_dirs, raw_paths: paths })
|
||||
}
|
||||
}
|
||||
|
||||
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 tc_id 重试计数。
|
||||
///
|
||||
/// 返回语义:
|
||||
/// - 0:审计表无该 tc_id 落定记录(或仅 pending),属首次审批执行,正常挂起。
|
||||
/// - ≥1:审计表已有该 tc_id 的落定记录(executed/failed/rejected/skipped_retry),即该
|
||||
/// tc_id 此前已被审批执行过一次,LLM 又用同 id 重试 → 调用方据 ≥1 跳过执行 + emit Completed,
|
||||
/// 断「超时/权限错→LLM 死循环重试同 id→重新挂起→用户被迫二次授权」循环。
|
||||
///
|
||||
/// 实现:查 `find_by_tool_call_id`,status 为 pending 视为"尚未落定"(返 0,首次挂起审批的
|
||||
/// 正常态);其余落定状态返 1。retry_count 当前仅取 0/1(断路器语义:第二次即跳过),
|
||||
/// 字段类型 u32 留给未来"允许多次重试"扩展(配置上限阈值)。
|
||||
///
|
||||
/// 兜底/回退:flag 关(文档标记)或审计查询失败 → 返 0,等价原行为(单次审批执行,无重试防护)。
|
||||
async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id: &str) -> u32 {
|
||||
// 审计查询失败不阻断主流程(DB 故障等降级为无重试防护,返回 0 走原审批流程)
|
||||
let rec = match audit_repo.find_by_tool_call_id(tc_id).await {
|
||||
Ok(opt) => match opt {
|
||||
Some(r) => r,
|
||||
None => return 0, // 无记录 = 首次
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("[阶段4-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
// pending = 首次挂起审批(尚未落定);其余落定状态 = 已执行过 → 计 1 次重试
|
||||
if rec.status == "pending" {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +269,7 @@ pub(crate) async fn process_tool_calls(
|
||||
let audit_repo = AiToolExecutionRepo::new(db);
|
||||
|
||||
// F-260619-04 P1 消息级溯源:取当前 assistant 消息 id。
|
||||
// 调用前 agentic.rs 已把本轮 assistant_with_tools 消息(LLM 返回带 tool_calls 的那条)
|
||||
// 调用前 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,展示侧兼容。
|
||||
@@ -298,8 +344,37 @@ pub(crate) async fn process_tool_calls(
|
||||
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
|
||||
for (draft, args, req) in path_auth_pending {
|
||||
pending_count += 1;
|
||||
let dir_str = req.dir.to_string_lossy().to_string();
|
||||
// L1 补丁:req.dirs 含所有未授权父目录;emit 用首个作主展示目录(前端弹窗主显)。
|
||||
// ai_authorize_dir 消费时遍历所有 dirs 写白名单。
|
||||
let dir_str = req.dirs.first()
|
||||
.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 重试检测。
|
||||
// 同 tool_call_id 在审计表已有一条落定记录(executed/failed/rejected,即已审批执行过一次)
|
||||
// → 推算 retry_count≥1,跳过 insert pending + emit Completed 带"已跳过重试"提示,
|
||||
// 断「超时/权限错→LLM 死循环重试同 id→重新挂起→用户被迫二次授权」循环。
|
||||
// 兜底:flag 关或无审计记录 → retry_count=0,等价原行为(正常挂起审批)。
|
||||
let retry_count = detect_retry_count(&audit_repo, &draft.id).await;
|
||||
if retry_count >= 1 {
|
||||
let skip_msg = format!(
|
||||
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
|
||||
draft.id
|
||||
);
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(skip_msg.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
audit_tool_call(
|
||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"),
|
||||
current_message_id,
|
||||
).await;
|
||||
continue;
|
||||
}
|
||||
session.pending_approvals.insert(
|
||||
draft.id.clone(),
|
||||
PendingApproval {
|
||||
@@ -308,12 +383,16 @@ pub(crate) async fn process_tool_calls(
|
||||
arguments: args.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
recovered: false,
|
||||
diff: None,
|
||||
path_auth: Some(req),
|
||||
// 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
|
||||
kind: ApprovalKind::Path(req),
|
||||
retry_count,
|
||||
},
|
||||
);
|
||||
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, PENDING_APPROVAL_PLACEHOLDER));
|
||||
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。
|
||||
// 阶段2:用 pending_placeholder_for 内嵌 __PENDING__:tc_id 标记,供 sanitize 反向 orphan
|
||||
// 检测豁免保留 + 出口断言自愈补头(防占位头被裁后 orphan result 触发 provider 400)。
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||||
tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)");
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiDirAuthRequired {
|
||||
id: draft.id.clone(),
|
||||
tool: draft.name.clone(),
|
||||
@@ -413,16 +492,38 @@ pub(crate) async fn process_tool_calls(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// 阶段4(容错/恢复,开关 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,断死循环。
|
||||
// 兜底:flag 关或无审计记录 → retry_count=0,等价原行为。
|
||||
let retry_count = detect_retry_count(&audit_repo, &draft.id).await;
|
||||
if retry_count >= 1 {
|
||||
let skip_msg = format!(
|
||||
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
|
||||
draft.id
|
||||
);
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(skip_msg.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id).await;
|
||||
continue;
|
||||
}
|
||||
session.pending_approvals.insert(draft.id.clone(), PendingApproval {
|
||||
tool_call_id: draft.id.clone(),
|
||||
tool_name: draft.name.clone(),
|
||||
arguments: args.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
recovered: false,
|
||||
diff: approval_diff.clone(),
|
||||
path_auth: None,
|
||||
// 阶段3a:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。
|
||||
kind: ApprovalKind::Risk { diff: approval_diff.clone() },
|
||||
retry_count,
|
||||
});
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, PENDING_APPROVAL_PLACEHOLDER));
|
||||
// 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan)
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||||
let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiApprovalRequired {
|
||||
id: draft.id.clone(),
|
||||
@@ -438,7 +539,7 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
|
||||
// AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,非"锁已释放"——
|
||||
// session 锁仍由调用方 agentic.rs 持有至 process_tool_calls 返回,CR-53 审查纠正原"移锁外"误述),
|
||||
// session 锁仍由调用方 agentic/mod.rs 持有至 process_tool_calls 返回,CR-53 审查纠正原"移锁外"误述),
|
||||
// push tool_result / audit 在 join_all 后串行回填(持锁)。对齐 Low risk 并行模式。
|
||||
// CR-51 修:原 inline .await execute 串行执行每个工具(阻塞期间锁被持有,run_command 慢命令
|
||||
// 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间(锁持有时长不变,并行化降阻塞)。
|
||||
@@ -551,5 +652,66 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段3a:单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
|
||||
let (path_count, risk_count) = session.pending_approvals.values()
|
||||
.fold((0usize, 0usize), |(p, r), a| match a.kind {
|
||||
ApprovalKind::Path(_) => (p + 1, r),
|
||||
ApprovalKind::Risk { .. } => (p, r + 1),
|
||||
});
|
||||
tracing::debug!(target: "ai_dirauth", pending_count, pending_approvals_len = session.pending_approvals.len(), path_count, risk_count, "process_tool_calls 返回(路径/风险挂起分布)");
|
||||
pending_count
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。
|
||||
///
|
||||
/// F-260621:grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
|
||||
/// AiDirAuthRequired 申请(对齐 read_file),不像 search_files 被硬拒。
|
||||
/// 锁定此差异:grep 与 read_file 同款授权弹窗语义。
|
||||
#[test]
|
||||
fn test_grep_unauthorized_path_triggers_auth_not_denied() {
|
||||
// 空白名单(不含 workspace_root 的目录):任何路径都未授权 → NeedsAuth
|
||||
let allowed = crate::state::AllowedDirs::default();
|
||||
let args = serde_json::json!({ "pattern": "foo", "path": "C:/some/unauthorized/dir" });
|
||||
|
||||
let outcome = check_file_tool_auth("grep", &args, &allowed);
|
||||
match outcome {
|
||||
FileToolAuthOutcome::NeedsAuth(_) => { /* 期望:grep 触发授权申请 */ }
|
||||
FileToolAuthOutcome::Authorized => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Authorized"),
|
||||
FileToolAuthOutcome::Denied(_) => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Denied(grep 不应像 search_files 盲拒)"),
|
||||
}
|
||||
|
||||
// 对照:search_files 同样未授权但被硬拒(Denied),锁定两工具差异
|
||||
let search_outcome = check_file_tool_auth("search_files", &args, &allowed);
|
||||
match search_outcome {
|
||||
FileToolAuthOutcome::Denied(_) => { /* 期望:search_files 盲拒 */ }
|
||||
FileToolAuthOutcome::NeedsAuth(_) => panic!("search_files 未授权应返 Denied(盲拒),实际 NeedsAuth"),
|
||||
FileToolAuthOutcome::Authorized => panic!("search_files 未授权应返 Denied(盲拒),实际 Authorized"),
|
||||
}
|
||||
}
|
||||
|
||||
/// grep 黑名单路径(.ssh 等)直接 Denied(不申请授权),对齐 read_file。
|
||||
#[test]
|
||||
fn test_grep_blacklisted_path_denied() {
|
||||
let allowed = crate::state::AllowedDirs::default_with_root();
|
||||
// .ssh 命中系统黑名单(is_in_system_blacklist)
|
||||
let args = serde_json::json!({ "pattern": "foo", "path": "/home/user/.ssh/config" });
|
||||
let outcome = check_file_tool_auth("grep", &args, &allowed);
|
||||
match outcome {
|
||||
FileToolAuthOutcome::Denied(_) => { /* 期望:黑名单硬拒 */ }
|
||||
FileToolAuthOutcome::NeedsAuth(_) => panic!("grep 黑名单路径应返 Denied,实际 NeedsAuth"),
|
||||
FileToolAuthOutcome::Authorized => panic!("grep 黑名单路径应返 Denied,实际 Authorized"),
|
||||
}
|
||||
}
|
||||
|
||||
/// extract_file_tool_paths:grep 取 args["path"](单路径分支)
|
||||
#[test]
|
||||
fn test_extract_grep_path() {
|
||||
let args = serde_json::json!({ "pattern": "foo", "path": "src/main.rs", "glob": "*.rs" });
|
||||
let paths = extract_file_tool_paths("grep", &args);
|
||||
assert_eq!(paths, vec!["src/main.rs".to_string()], "grep 应取 path 参数(忽略 glob 等其他参数)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::collections::HashSet;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::super::PendingApproval;
|
||||
use super::super::{ApprovalKind, PendingApproval};
|
||||
use super::risk_from_str;
|
||||
|
||||
/// 启动恢复:从审计表重建 pending_approvals(重启前未审批的工具调用,内存态已丢)
|
||||
@@ -64,19 +64,26 @@ pub async fn restore_pending_approvals(state: &AppState) {
|
||||
arguments: args,
|
||||
conversation_id: rec.conversation_id,
|
||||
recovered: true,
|
||||
// 阶段3a 单真相源合并:恢复的审批一律 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。
|
||||
diff: None,
|
||||
// F-260619-03 Phase B: 重启恢复的审批一律视为普通 RiskLevel 审批(非路径授权挂起)。
|
||||
// 路径授权挂起是会话级状态,重启后 session 重建,无法恢复挂起语义。
|
||||
path_auth: None,
|
||||
kind: ApprovalKind::Risk { diff: None },
|
||||
// 阶段4:重启恢复的审批 retry_count=0(恢复语义即"待用户首次决策",非重试)。
|
||||
// 即便审计表已有 pending 记录,恢复后用户审批执行属首次正常执行,不断路。
|
||||
retry_count: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
"启动恢复: {} 条 pending 工具审批重建到内存, 分布 {} 个 conv 的 per_conv",
|
||||
"启动恢复: {} 条 pending 工具审批重建到内存(pending_approvals), 分布 {} 个 conv 的 per_conv",
|
||||
session.pending_approvals.len(),
|
||||
convs_restored.len()
|
||||
);
|
||||
@@ -84,7 +91,7 @@ pub async fn restore_pending_approvals(state: &AppState) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_f09_batch8_restore {
|
||||
use super::super::PendingApproval;
|
||||
use super::super::{ApprovalKind, PendingApproval};
|
||||
use super::*;
|
||||
use crate::commands::ai::AiSession;
|
||||
|
||||
@@ -114,7 +121,7 @@ mod tests_f09_batch8_restore {
|
||||
let _ = session.conv(cid); // 惰性建
|
||||
}
|
||||
}
|
||||
// pending 入单层 HashMap(不进 per_conv,设计 §2.1.2)。
|
||||
// 阶段3a 单真相源合并:恢复的 pending 全部进 pending_approvals,kind=Risk。
|
||||
session.pending_approvals.insert(
|
||||
tool_call_id.clone(),
|
||||
PendingApproval {
|
||||
@@ -123,8 +130,9 @@ mod tests_f09_batch8_restore {
|
||||
arguments: serde_json::Value::Null,
|
||||
conversation_id: conversation_id.clone(),
|
||||
recovered: true,
|
||||
diff: None,
|
||||
path_auth: None,
|
||||
kind: ApprovalKind::Risk { diff: None },
|
||||
// 阶段4:恢复的审批 retry_count=0(首次用户决策,非重试)。
|
||||
retry_count: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -138,8 +146,17 @@ mod tests_f09_batch8_restore {
|
||||
"未恢复的 conv 不应被惰性建"
|
||||
);
|
||||
|
||||
// 不变量 2:pending_approvals 单层 HashMap 4 条(含无主),conversation_id 保留(业务语义)。
|
||||
assert_eq!(session.pending_approvals.len(), 4, "全部 pending 入单层 HashMap");
|
||||
// 不变量 2:pending_approvals HashMap 4 条(含无主),conversation_id 保留(业务语义)。
|
||||
// 阶段3a:全部进单表 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。这是构造恒等式而非外部不变量——
|
||||
// 对齐真实 restore_pending_approvals 的构造(实现 L78 一律 kind=Risk,无 Path 分支)。
|
||||
// 语义:首次 restore 无历史(Path 审批是会话级状态不恢复,恢复侧构造时本就不产出 Path)。
|
||||
let path_restored = session.pending_approvals.values()
|
||||
.filter(|a| matches!(a.kind, ApprovalKind::Path(_)))
|
||||
.count();
|
||||
assert_eq!(path_restored, 0, "构造期恒等式:恢复侧构造全部 kind=Risk,无 Path(首次 restore 无历史)");
|
||||
let conv_a_pending: Vec<_> = session
|
||||
.pending_approvals
|
||||
.values()
|
||||
@@ -157,7 +174,7 @@ mod tests_f09_batch8_restore {
|
||||
.values()
|
||||
.filter(|a| a.conversation_id.is_none())
|
||||
.collect();
|
||||
assert_eq!(ownerless.len(), 1, "无主审批 1 条仍入单层表(R-9)");
|
||||
assert_eq!(ownerless.len(), 1, "无主审批 1 条仍入 pending_approvals(R-9)");
|
||||
|
||||
// 不变量 3:同 conv 多条 pending 复用同一 PerConvState(conv() 幂等,不重复建)。
|
||||
let conv_a_ptr = session.conv("conv-a") as *const _;
|
||||
|
||||
Reference in New Issue
Block a user