优化: 边界加固(AI loop竞态根治+数据/审批/反馈/并发/错误分类)

AI loop 竞态(P0):per-conv epoch/owner token + 存活心跳治 force_send 双loop + stop 3s兜底误判;旧loop stale 全跳过(guard/emit/save)

agentic 收尾(A2-B8):Fatal 退出落库user消息(镜像Exhausted)+ 入口早退补save + usage is_estimated 打标 + emit_ai_completed_once 单点收敛清审批残留

聊天清理(A2-B9):clearChat 先停loop→DB单事务→内存清(clear_conversation_atomic)+ 前端错误气泡

循环并发(A2-B11):三态 ProviderAcquire(NotConfigured/Acquired/Exhausted)+ 候选循环非阻塞+防抖3次饱和降级+单测

错误分类(A2-B12):stream error帧接入 classify_status_or_class + 关键词保守降级 + 7单测

数据(G1.2/G1.4):purge_with_descendants 级联补全(11表单事务+存在性守卫)+ move_task_queue 单事务收口(两调用方共用)

git只读(G3.1):run_git_status/diff/log success判定(exit_code差异语义,失败结构化{success:false,error})

安全(G5.2/G5.6):create_project 目录Err+name校验 + module.rs 路径遍历DRY(分段匹配修a..b.rs误伤)

幂等(V2/V32):裸ALTER全守卫化 + v1..v40全链重跑幂等测试(16过)

附:remote_bridge await 临时引用修(E0716)+ agentic emit 收敛 E0716 app_state 绑定修
This commit is contained in:
lxy
2026-08-05 22:10:32 +08:00
parent ec9f0bf1ea
commit 5667da6cf4
16 changed files with 1696 additions and 215 deletions
+97 -35
View File
@@ -24,11 +24,15 @@ use df_storage::db::Database;
// Git 私有 helper(原 tool_registry.rs 986-1080 行原样搬入,仅本模块使用)
// ============================================================
/// 在指定目录执行 git 命令(10s 超时,返回 (stdout, success))。
/// 在指定目录执行 git 命令(10s 超时,返回 (stdout_or_stderr, exit_code))。
///
/// 返回 (stdout, success):
/// - success = child 退出码为 0(status.success());
/// - 失败时 stdout 含 stderr 内容(便于上层拼 reason);超时/启动失败 → (空串, false)
/// 返回 (String, Option<i32>):
/// - String: 成功时 stdout;失败(exit != 0)时 stderr 非空则 stderr,否则 stdout;
/// 启动失败/超时 → 空串。
/// - Option<i32>: 正常退出为 Some(退出码);启动失败/超时/信号终止为 None。
///
/// 只读工具(status/diff/log)直接调本函数拿退出码做差异语义:git diff 退 1 = 有差异是
/// **合法成功**、git log 退 128 = 非 git 仓是失败;单纯 bool(exit==0)无法区分。
///
/// BUG-2026-07-18: 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout
/// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限,
@@ -41,7 +45,7 @@ use df_storage::db::Database;
/// BUG-2026-08-02: 原签名返 String,失败/超时统一吞成空串,使 create/switch/commit 三个写操作
/// 恒返成功假象(commit 失败 HEAD 不动,log -1 仍返上次 hash → committed:true)。
/// 改返 (String, bool),上层据 success 判定真实成败。
async fn exec_git(working_dir: &str, args: &[&str]) -> (String, bool) {
async fn exec_git_exit(working_dir: &str, args: &[&str]) -> (String, Option<i32>) {
let mut cmd = tokio::process::Command::new("git");
cmd.args(args)
.current_dir(working_dir)
@@ -56,32 +60,56 @@ async fn exec_git(working_dir: &str, args: &[&str]) -> (String, bool) {
}
match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await {
Ok(Ok(out)) => {
let success = out.status.success();
// 失败时把 stderr 拼进 stdout 返回(上层据 success=false 读 reason)
let code = out.status.code();
// 失败时把 stderr 拼进 String 返回(上层据 exit_code != 0 读 reason)
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
if success {
(stdout, true)
if code == Some(0) {
(stdout, code)
} else {
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
if stderr.is_empty() {
(stdout, false)
(stdout, code)
} else {
(stderr, false)
(stderr, code)
}
}
}
Ok(Err(_)) => (String::new(), false),
Err(_elapsed) => (String::new(), false), // 超时:child 被 kill_on_drop 终止
Ok(Err(_)) => (String::new(), None),
Err(_elapsed) => (String::new(), None), // 超时:child 被 kill_on_drop 终止
}
}
/// 在指定目录执行 git 命令,返回 (stdout_or_stderr, success)。
///
/// success = exit_code == 0(对齐 run_command succeeded=exit_code==0 口径)。
/// 写工具(commit/branch/merge)继续用本函数;只读工具(status/diff/log)改用
/// exec_git_exit 拿退出码做差异语义(diff 退 1 有差异是成功、log 退 128 非 git 仓才失败)。
async fn exec_git(working_dir: &str, args: &[&str]) -> (String, bool) {
let (out, code) = exec_git_exit(working_dir, args).await;
(out, code == Some(0))
}
/// git status --porcelain 解析为结构化文件列表。
/// 返回 (当前分支, 改动文件列表 [{path, status}])
async fn run_git_status(working_dir: &str) -> (String, Vec<serde_json::Value>) {
let branch = exec_git(working_dir, &["branch", "--show-current"]).await.0;
let branch = branch.trim().to_string();
let raw = exec_git(working_dir, &["status", "--porcelain"]).await.0;
let files: Vec<serde_json::Value> = raw
///
/// 成功判定(对齐 run_command succeeded=exit_code==0 口径):`status --porcelain`
/// 退出码 0 = 命令可达 + 非 fatal,即成功——空输出(工作区干净)是**合法成功**,非失败。
/// 仅命令不可达/超时(None)或非 git 仓/权限(Some(128) 等)才判失败。
///
/// 返回:成功 {branch, files, total_changes} / 失败 {success:false, error, branch}。
async fn run_git_status(working_dir: &str) -> serde_json::Value {
// branch 解析(失败时 branch 也标注,保留当前已取到的值,可能为空/fatal 提示)
let (branch_raw, _) = exec_git_exit(working_dir, &["branch", "--show-current"]).await;
let branch = branch_raw.trim().to_string();
// status --porcelain:exit 0 = 干净或改动均合法成功
let (status_raw, status_code) = exec_git_exit(working_dir, &["status", "--porcelain"]).await;
if status_code != Some(0) {
return serde_json::json!({
"success": false,
"error": status_raw.trim(),
"branch": branch,
});
}
let files: Vec<serde_json::Value> = status_raw
.lines()
.filter(|l| !l.is_empty())
.map(|line| {
@@ -97,19 +125,37 @@ async fn run_git_status(working_dir: &str) -> (String, Vec<serde_json::Value>) {
serde_json::json!({ "path": path, "status": simple })
})
.collect();
(branch, files)
serde_json::json!({
"branch": branch,
"files": files,
"total_changes": files.len(),
})
}
/// git diff 解析为结构化文件列表(每文件统计 + patch 截断)。
///
/// git 语义注意:diff 退出码 **0 = 无差异、1 = 有差异,二者均为合法成功**
/// (不能按 exit==0 判成功,否则有差异的正常 diff 会被误判失败)。仅命令不可达/
/// 超时(None)或 >1(非 git 仓/权限/坏路径)才算失败。
///
/// 返回:成功 {stat, patch} / 失败 {success:false, error}。
async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value {
let mut args = vec!["diff", "--stat"];
if staged { args.push("--cached"); }
let stat_raw = exec_git(working_dir, &args).await.0;
let (stat_raw, stat_code) = exec_git_exit(working_dir, &args).await;
// 0(无差异)/1(有差异)均成功;None(不可达/超时)或 >1(非 git 仓/权限)才失败
let stat_ok = matches!(stat_code, Some(0) | Some(1));
if !stat_ok {
return serde_json::json!({
"success": false,
"error": stat_raw.trim(),
});
}
// 每文件 patch(截断防 token 爆)
let mut patch_args = vec!["diff" ];
let mut patch_args = vec!["diff"];
if staged { patch_args.push("--cached"); }
let patch_raw = exec_git(working_dir, &patch_args).await.0;
let patch_raw = exec_git_exit(working_dir, &patch_args).await.0;
// 截断到 8000 字符(防大体量 diff)
let patch_truncated = if patch_raw.len() > 8000 {
format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..8000], patch_raw.len())
@@ -124,11 +170,30 @@ async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value {
}
/// git log 解析为结构化提交列表。
async fn run_git_log(working_dir: &str, limit: usize) -> Vec<serde_json::Value> {
///
/// 成功判定:exit 0 = 正常(提交列表,可为空);**空仓 git log 返 128 +
/// "does not have any commits yet" 视为合法空成功**(非失败);仅 exit != 0 且非
/// 空仓提示(非 git 仓 128 等)才失败。
///
/// 返回:成功 {commits} / 失败 {success:false, error}。
async fn run_git_log(working_dir: &str, limit: usize) -> serde_json::Value {
let format = "%H|%an|%ad|%s";
let limit_str = format!("-{}", limit);
let raw = exec_git(working_dir, &["log", "--oneline", &format!("--format={}", format), &limit_str, "--date=short"]).await.0;
raw.lines()
let (raw, code) = exec_git_exit(
working_dir,
&["log", "--oneline", &format!("--format={}", format), &limit_str, "--date=short"],
).await;
// 空仓:git log 返 128 + 该提示 → 空提交列表是合法成功(对齐 git_commit 的
// "nothing to commit" 同型已知提示判断)
let empty_repo = code == Some(128) && raw.contains("does not have any commits yet");
if code != Some(0) && !empty_repo {
return serde_json::json!({
"success": false,
"error": raw.trim(),
});
}
let commits: Vec<serde_json::Value> = raw
.lines()
.filter(|l| !l.is_empty())
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(4, '|').collect();
@@ -141,7 +206,8 @@ async fn run_git_log(working_dir: &str, limit: usize) -> Vec<serde_json::Value>
}))
} else { None }
})
.collect()
.collect();
serde_json::json!({ "commits": commits })
}
/// Git AI 工具注册(6 个:status/diff/log 只读 Low + commit/branch 写 Medium + merge High)。
@@ -169,12 +235,8 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
let (branch, files) = run_git_status(&module.path).await;
Ok(serde_json::json!({
"branch": branch,
"files": files,
"total_changes": files.len(),
}))
let result = run_git_status(&module.path).await;
Ok(result)
}
);
@@ -219,8 +281,8 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
let commits = run_git_log(&module.path, limit).await;
Ok(serde_json::json!({ "commits": commits }))
let result = run_git_log(&module.path, limit).await;
Ok(result)
}
);