优化: 边界加固(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:
@@ -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)
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user