新增: 仲裁合并(冲突检测+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]

View File

@@ -811,4 +811,6 @@ export interface ConflictInfo {
conflict_type: ConflictType
subtask_a: string | null
subtask_b: string | null
diff_a?: string | null
diff_b?: string | null
}

View File

@@ -0,0 +1,119 @@
<template>
<div class="conflict-resolver" v-if="conflicts.length > 0">
<div class="conflict-header">
<span class="conflict-title"> {{ t('aiChat.conflictsPending', { n: conflicts.length }) }}</span>
</div>
<div v-for="c in conflicts" :key="c.id" class="conflict-item">
<div class="conflict-file">
<span class="conflict-type-badge" :class="'badge--' + c.conflict_type">
{{ c.conflict_type === 'semantic' ? '语义' : '文件' }}
</span>
<code>{{ c.file_path }}</code>
</div>
<div class="conflict-diffs" v-if="c.diff_a || c.diff_b">
<div class="diff-panel diff-panel--a">
<div class="diff-panel-label">Agent A ({{ c.subtask_a || '?' }})</div>
<pre class="diff-content">{{ c.diff_a || '(无改动)' }}</pre>
</div>
<div class="diff-panel diff-panel--b">
<div class="diff-panel-label">Agent B ({{ c.subtask_b || '?' }})</div>
<pre class="diff-content">{{ c.diff_b || '(无改动)' }}</pre>
</div>
</div>
<div class="conflict-actions">
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'a' })">
{{ t('aiChat.acceptA') }}
</button>
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'b' })">
{{ t('aiChat.acceptB') }}
</button>
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'merged' })">
{{ t('aiChat.mergeBoth') }}
</button>
<button class="btn btn-sm btn-ghost" @click="emit('resolve', { id: c.id, resolution: 'manual' })">
{{ t('aiChat.manualResolve') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { ConflictInfo } from '@/api/types'
const { t } = useI18n()
defineProps<{
conflicts: ConflictInfo[]
}>()
const emit = defineEmits<{
(e: 'resolve', payload: { id: string; resolution: string }): void
}>()
</script>
<style scoped>
.conflict-resolver {
border: 1px solid var(--df-warning, #ffc107);
border-radius: 8px;
padding: 8px 12px;
margin: 8px 0;
background: var(--df-bg-secondary);
}
.conflict-header {
font-weight: 600;
margin-bottom: 8px;
color: var(--df-warning, #ffc107);
}
.conflict-item {
border-top: 1px solid var(--df-border);
padding: 8px 0;
}
.conflict-file {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.conflict-type-badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
}
.badge--file { background: rgba(255, 193, 7, 0.15); color: #ffc107; }
.badge--semantic { background: rgba(220, 53, 69, 0.15); color: #dc3545; }
.conflict-file code { font-size: 12px; color: var(--df-text); }
.conflict-diffs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 8px;
}
.diff-panel {
border: 1px solid var(--df-border);
border-radius: 4px;
overflow: hidden;
}
.diff-panel--a { border-left: 3px solid var(--df-primary); }
.diff-panel--b { border-left: 3px solid var(--df-success); }
.diff-panel-label {
font-size: 11px;
padding: 2px 8px;
background: var(--df-bg);
color: var(--df-text-dim);
}
.diff-content {
font-size: 11px;
padding: 4px 8px;
margin: 0;
max-height: 200px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.conflict-actions {
display: flex;
gap: 4px;
}
</style>

View File

@@ -3,6 +3,11 @@ export default {
// ── Multi-Agent parallel execution ──
planProgress: 'Execution Plan',
planLayer: 'Layer {n}',
conflictsPending: '{n} conflicts to resolve',
acceptA: 'Accept A',
acceptB: 'Accept B',
mergeBoth: 'Merge Both',
manualResolve: 'Manual',
// ── Conversation sidebar ──
sidebarTitle: 'Chats',

View File

@@ -3,6 +3,11 @@ export default {
// ── 多 Agent 并行执行 ──
planProgress: '执行计划',
planLayer: '第 {n} 层',
conflictsPending: '{n} 处冲突待处理',
acceptA: '接受 A',
acceptB: '接受 B',
mergeBoth: '合并两者',
manualResolve: '手动处理',
// ── 对话侧栏 ──
sidebarTitle: '对话',