新增: 仲裁合并(冲突检测+ConflictResolver+reviewer仲裁基础)
- Coordinator.merge: 跳过失败SubTask+同文件冲突检测+extract_written_files - ConflictResolver.vue: 双栏diff展示+接受A/接受B/合并/手动按钮 - i18n: 冲突相关翻译键(中英文) - 7个merge测试全绿(含4个新增冲突检测)
This commit is contained in:
@@ -385,12 +385,16 @@ impl Coordinator {
|
||||
results
|
||||
}
|
||||
|
||||
/// 合并:汇总子结果 → 合并产出
|
||||
/// 合并:汇总子结果 → 合并产出 + 冲突检测
|
||||
///
|
||||
/// Phase 1:简单拼接各 SubTask 产出,每段带标题标识来源。
|
||||
/// `conflicts` 返回空 vec(Phase 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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
119
src/components/ai/ConflictResolver.vue
Normal file
119
src/components/ai/ConflictResolver.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -3,6 +3,11 @@ export default {
|
||||
// ── 多 Agent 并行执行 ──
|
||||
planProgress: '执行计划',
|
||||
planLayer: '第 {n} 层',
|
||||
conflictsPending: '{n} 处冲突待处理',
|
||||
acceptA: '接受 A',
|
||||
acceptB: '接受 B',
|
||||
mergeBoth: '合并两者',
|
||||
manualResolve: '手动处理',
|
||||
|
||||
// ── 对话侧栏 ──
|
||||
sidebarTitle: '对话',
|
||||
|
||||
Reference in New Issue
Block a user