新增: 仲裁合并(冲突检测+ConflictResolver+reviewer仲裁基础)

- Coordinator.merge: 跳过失败SubTask+同文件冲突检测+extract_written_files

- ConflictResolver.vue: 双栏diff展示+接受A/接受B/合并/手动按钮

- i18n: 冲突相关翻译键(中英文)

- 7个merge测试全绿(含4个新增冲突检测)
This commit is contained in:
2026-07-01 22:32:27 +08:00
parent 9947e6c1fe
commit 1d580dccc3
5 changed files with 298 additions and 5 deletions

View File

@@ -385,12 +385,16 @@ impl Coordinator {
results
}
/// 合并汇总子结果 → 合并产出
/// 合并:汇总子结果 → 合并产出 + 冲突检测
///
/// Phase 1简单拼接各 SubTask 产出,每段带标题标识来源。
/// `conflicts` 返回空 vecPhase 2 由 reviewer Agent 仲裁填充)。
/// - 成功的 SubTask 产出拼接为 merged_output
/// - 失败的 SubTask 跳过(不参与合并)
/// - 冲突检测:多个 SubTask 写同一文件路径 → 标记冲突
pub fn merge(&self, results: &[ExecutionResult]) -> MergeResult {
let parts: Vec<String> = results
let success_results: Vec<&ExecutionResult> =
results.iter().filter(|r| r.success).collect();
let parts: Vec<String> = success_results
.iter()
.map(|r| {
format!(
@@ -400,11 +404,68 @@ impl Coordinator {
})
.collect();
// 冲突检测:检查是否有多个 SubTask 改了同一文件
let conflicts = self.detect_file_conflicts(results);
MergeResult {
merged_output: parts.join("\n\n---\n\n"),
conflicts: Vec::new(),
conflicts,
}
}
/// 检测文件冲突:从 ExecutionResult 中提取写入的文件路径,同路径 → 冲突
/// Phase 1:基于 output 文本中的文件路径关键词(简化检测)
/// Phase 2:从 Git worktree 的 git diff 提取精确路径
fn detect_file_conflicts(&self, results: &[ExecutionResult]) -> Vec<ConflictItem> {
use std::collections::HashMap;
// 收集每个 SubTask 写入的文件路径(从 output 中提取)
let mut file_map: HashMap<String, Vec<String>> = HashMap::new();
for r in results {
if !r.success {
continue;
}
let files = extract_written_files(&r.output);
for f in files {
file_map.entry(f).or_default().push(r.subtask_id.clone());
}
}
// 同文件被多个 SubTask 写 → 冲突
let mut conflicts = Vec::new();
for (file, subtasks) in &file_map {
if subtasks.len() > 1 {
conflicts.push(ConflictItem {
file: file.clone(),
description: format!(
"文件 {} 被 {} 个子任务同时修改: {}",
file,
subtasks.len(),
subtasks.join(", ")
),
});
}
}
conflicts
}
}
/// 从执行产出文本中提取被写入的文件路径(简化检测:匹配 write_file/patch_file 后的路径)
fn extract_written_files(output: &str) -> Vec<String> {
let mut files = Vec::new();
for line in output.lines() {
let trimmed = line.trim();
// 匹配 "write_file: path" 或 "patch_file: path" 或 "写入: path" 模式
for prefix in ["write_file:", "patch_file:", "写入:", "修改:"] {
if let Some(rest) = trimmed.strip_prefix(prefix) {
let path = rest.trim().split_whitespace().next().unwrap_or("");
if !path.is_empty() {
files.push(path.to_string());
}
}
}
}
files
}
// ---- 单元测试 ---------------------------------------------------------------
@@ -621,6 +682,107 @@ mod tests {
assert!(merged.conflicts.is_empty());
}
#[test]
fn merge_skips_failed_results() {
let coord = make_coord();
let results = vec![
ExecutionResult {
subtask_id: "ok".into(),
persona_id: "coder".into(),
output: "成功产出".into(),
success: true,
},
ExecutionResult {
subtask_id: "fail".into(),
persona_id: "coder".into(),
output: "执行失败".into(),
success: false,
},
];
let merged = coord.merge(&results);
assert!(merged.merged_output.contains("成功产出"), "应含成功产出");
assert!(!merged.merged_output.contains("执行失败"), "不应含失败产出");
}
#[test]
fn merge_detects_same_file_conflict() {
let coord = make_coord();
let results = vec![
ExecutionResult {
subtask_id: "A".into(),
persona_id: "coder".into(),
output: "write_file: src/main.rs\n新增 auth 模块".into(),
success: true,
},
ExecutionResult {
subtask_id: "B".into(),
persona_id: "coder".into(),
output: "write_file: src/main.rs\n新增 payment 模块".into(),
success: true,
},
];
let merged = coord.merge(&results);
assert_eq!(merged.conflicts.len(), 1, "同文件应检测到 1 个冲突");
assert!(merged.conflicts[0].file.contains("main.rs"));
assert!(merged.conflicts[0].description.contains("A"));
assert!(merged.conflicts[0].description.contains("B"));
}
#[test]
fn merge_no_conflict_different_files() {
let coord = make_coord();
let results = vec![
ExecutionResult {
subtask_id: "A".into(),
persona_id: "coder".into(),
output: "write_file: src/auth.rs".into(),
success: true,
},
ExecutionResult {
subtask_id: "B".into(),
persona_id: "coder".into(),
output: "write_file: src/payment.rs".into(),
success: true,
},
];
let merged = coord.merge(&results);
assert!(merged.conflicts.is_empty(), "不同文件不应有冲突");
}
#[test]
fn merge_conflict_ignored_for_failed() {
let coord = make_coord();
// 失败的 SubTask 不参与冲突检测
let results = vec![
ExecutionResult {
subtask_id: "A".into(),
persona_id: "coder".into(),
output: "write_file: src/main.rs".into(),
success: true,
},
ExecutionResult {
subtask_id: "B".into(),
persona_id: "coder".into(),
output: "write_file: src/main.rs".into(),
success: false, // 失败
},
];
let merged = coord.merge(&results);
assert!(merged.conflicts.is_empty(), "失败的不应参与冲突检测");
}
#[test]
fn extract_files_from_output() {
let files = extract_written_files("write_file: src/main.rs\n其他内容");
assert_eq!(files, vec!["src/main.rs"]);
let files = extract_written_files("patch_file: lib/utils.rs\n修改完成");
assert!(files.contains(&"lib/utils.rs".to_string()));
let files = extract_written_files("没有写操作");
assert!(files.is_empty());
}
// -- DispatchStrategy --
#[test]