- coordinator: arbitrate_conflicts 方法(规则仲裁:成功方优先/双方成功推荐合并/都失败手动) - coordinator: ConflictItem 加 subtask_a/subtask_b/recommendation 字段 + ConflictResolution 枚举 - command_lock.rs: CommandLockRegistry (dir+cmd首词 Semaphore 串行化,5个测试全绿) - resolve_conflict IPC: 解决冲突→更新DB→emit AiConflictResolved 事件闭环 - 3个仲裁测试 + 5个互斥锁测试全绿 - 零编译警告
1114 lines
38 KiB
Rust
1114 lines
38 KiB
Rust
//! 多 Agent 协作调度中心 — Coordinator
|
||
//!
|
||
//! Phase 1 实现,Phase 2 LLM 扩展
|
||
//!
|
||
//! 职责拆解(4 步):
|
||
//! 1. **decompose**:接收用户意图 + 消息 → 拆解为 SubTask 列表
|
||
//! 2. **分配**:为每个 SubTask 分配人设(PersonaRegistry.recommend_for_intent)
|
||
//! 3. **dispatch**:并行执行 SubTask(当前 Phase 1 串行,预留 JoinSet 并行)
|
||
//! 4. **merge**:汇总子结果 → 合并产出 → 处理冲突
|
||
|
||
use crate::persona::PersonaRegistry;
|
||
use crate::planner::{Plan, SubTask};
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use std::sync::Arc;
|
||
|
||
// ---- Token 预算池 ------------------------------------------------------------
|
||
|
||
/// 全局 Token 预算池(CAS 无锁并发安全)
|
||
/// 多个 SubTask 启动前向预算池申请估算额度,超限时降级串行(不拒绝执行)。
|
||
#[derive(Debug)]
|
||
pub struct TokenBudgetPool {
|
||
total: AtomicU64,
|
||
consumed: AtomicU64,
|
||
}
|
||
|
||
impl TokenBudgetPool {
|
||
/// 创建预算池。total=0 表示不限制(等价无限)。
|
||
pub fn new(total: u64) -> Arc<Self> {
|
||
Arc::new(Self {
|
||
total: AtomicU64::new(total),
|
||
consumed: AtomicU64::new(0),
|
||
})
|
||
}
|
||
|
||
/// 尝试预占额度。成功返回 true,超限返回 false。
|
||
/// total=0 时不限制,始终返回 true。
|
||
pub fn try_reserve(&self, estimate: u64) -> bool {
|
||
let total = self.total.load(Ordering::SeqCst);
|
||
if total == 0 {
|
||
return true;
|
||
}
|
||
let mut consumed = self.consumed.load(Ordering::SeqCst);
|
||
loop {
|
||
if consumed + estimate > total {
|
||
return false;
|
||
}
|
||
match self.consumed.compare_exchange_weak(
|
||
consumed,
|
||
consumed + estimate,
|
||
Ordering::SeqCst,
|
||
Ordering::SeqCst,
|
||
) {
|
||
Ok(_) => return true,
|
||
Err(actual) => consumed = actual,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 已消耗额度
|
||
pub fn consumed(&self) -> u64 {
|
||
self.consumed.load(Ordering::SeqCst)
|
||
}
|
||
|
||
/// 总预算(0=不限制)
|
||
pub fn total(&self) -> u64 {
|
||
self.total.load(Ordering::SeqCst)
|
||
}
|
||
}
|
||
|
||
// ---- 枚举 --------------------------------------------------------------------
|
||
|
||
/// 调度策略枚举
|
||
pub enum DispatchStrategy {
|
||
/// 规则驱动(关键词匹配,Phase 1 MVP)
|
||
RuleBased,
|
||
/// LLM 驱动(LLM 生成 Plan,Phase 2 预留)
|
||
LLMDriven,
|
||
}
|
||
|
||
impl DispatchStrategy {
|
||
/// 策略名称标签(日志 / 审计用)
|
||
pub fn name(&self) -> &'static str {
|
||
match self {
|
||
Self::RuleBased => "rule_based",
|
||
Self::LLMDriven => "llm_driven",
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- 数据结构 ----------------------------------------------------------------
|
||
|
||
/// 拆解结果
|
||
pub struct DecompositionResult {
|
||
/// 拆解出的子任务列表
|
||
pub subtasks: Vec<SubTask>,
|
||
/// 对应 Plan 结构(含依赖关系)
|
||
pub plan: Plan,
|
||
}
|
||
|
||
/// 执行结果
|
||
pub struct ExecutionResult {
|
||
/// 对应 SubTask id
|
||
pub subtask_id: String,
|
||
/// 分配的人设 id
|
||
pub persona_id: String,
|
||
/// 执行产出(工具结果摘要 / LLM 回复)
|
||
pub output: String,
|
||
/// 执行是否成功
|
||
pub success: bool,
|
||
}
|
||
|
||
/// 合并结果
|
||
pub struct MergeResult {
|
||
/// 合并后的最终产出
|
||
pub merged_output: String,
|
||
/// 检测到的冲突列表(Phase 2 由 reviewer Agent 填充)
|
||
pub conflicts: Vec<ConflictItem>,
|
||
}
|
||
|
||
/// 冲突项
|
||
pub struct ConflictItem {
|
||
/// 冲突涉及的文件
|
||
pub file: String,
|
||
/// 冲突描述
|
||
pub description: String,
|
||
/// 冲突双方 SubTask id
|
||
pub subtask_a: Option<String>,
|
||
pub subtask_b: Option<String>,
|
||
/// Reviewer 仲裁推荐(Phase 2 LLM 仲裁填充)
|
||
pub recommendation: Option<ConflictResolution>,
|
||
}
|
||
|
||
/// 冲突解决方案
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum ConflictResolution {
|
||
/// 接受 A 的改动
|
||
AcceptA,
|
||
/// 接受 B 的改动
|
||
AcceptB,
|
||
/// 合并两者
|
||
Merged,
|
||
/// 用户手动处理
|
||
Manual,
|
||
}
|
||
|
||
impl ConflictResolution {
|
||
pub fn as_str(&self) -> &'static str {
|
||
match self {
|
||
Self::AcceptA => "a",
|
||
Self::AcceptB => "b",
|
||
Self::Merged => "merged",
|
||
Self::Manual => "manual",
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- Coordinator 实现 --------------------------------------------------------
|
||
|
||
/// 多 Agent 协作调度器
|
||
///
|
||
/// 当前实现:
|
||
/// - 规则驱动 decompose(关键词匹配)
|
||
/// - dispatch_with_budget: JoinSet 层内并行 + token 预算管控
|
||
/// - dispatch_serial: 串行降级路径(无 'static 约束)
|
||
/// - merge: 拼接产出 + 同文件冲突检测
|
||
///
|
||
/// Phase 2 预留:
|
||
/// - LLM 驱动 decompose
|
||
/// - Reviewer Agent 仲裁
|
||
pub struct Coordinator {
|
||
/// 人设注册表(内置 5 人设 + 自定义)
|
||
registry: PersonaRegistry,
|
||
}
|
||
|
||
impl Coordinator {
|
||
/// 创建 Coordinator,绑定人设注册表
|
||
pub fn new(registry: PersonaRegistry) -> Self {
|
||
Self { registry }
|
||
}
|
||
|
||
/// 规则拆解:intent + text → SubTask 列表 + Plan
|
||
///
|
||
/// Phase 1 规则(关键词匹配):
|
||
/// - 检测 text 中是否含 `read`/`写`/`查看`/`分析`/`search` 等关键词 → 读子任务
|
||
/// - 检测 text 中是否含 `write`/`修改`/`生成`/`create`/`fix` 等关键词 → 写子任务
|
||
/// - 同时含读+写关键词 → 两个子任务,写依赖读(read → write)
|
||
/// - 每 SubTask 分配人设(`PersonaRegistry::recommend_for_intent`)
|
||
/// - 兜底:无法匹配任何关键词时产单个 default 子任务
|
||
///
|
||
/// ## Phase 2 扩展点
|
||
/// `DispatchStrategy::LLMDriven` 时改为调 LLM 生成 Plan 整体结构,
|
||
/// 本方法当前仅走 `RuleBased` 路径。
|
||
pub fn decompose(&self, intent: &str, text: &str) -> DecompositionResult {
|
||
let lower = text.to_lowercase();
|
||
|
||
// 规则 1: 读操作关键词
|
||
let has_read = lower.contains("read")
|
||
|| lower.contains("查看")
|
||
|| lower.contains("读取")
|
||
|| lower.contains("分析")
|
||
|| lower.contains("search")
|
||
|| lower.contains("搜索")
|
||
|| lower.contains("审查")
|
||
|| lower.contains("review");
|
||
|
||
// 规则 2: 写操作关键词
|
||
let has_write = lower.contains("write")
|
||
|| lower.contains("写")
|
||
|| lower.contains("修改")
|
||
|| lower.contains("生成")
|
||
|| lower.contains("创建")
|
||
|| lower.contains("implement")
|
||
|| lower.contains("fix")
|
||
|| lower.contains("修复")
|
||
|| lower.contains("新增");
|
||
|
||
let mut subtasks = Vec::new();
|
||
|
||
match (has_read, has_write) {
|
||
(true, true) => {
|
||
// 读 + 写 → 两个子任务:read → write
|
||
subtasks.push(
|
||
SubTask::new("read", "读取/分析现有代码")
|
||
.with_tools(vec![
|
||
"read_file".into(),
|
||
"search_files".into(),
|
||
"list_directory".into(),
|
||
])
|
||
.with_deps(vec![]),
|
||
);
|
||
subtasks.push(
|
||
SubTask::new("write", "修改/生成代码")
|
||
.with_tools(vec![
|
||
"write_file".into(),
|
||
"patch_file".into(),
|
||
"edit_file".into(),
|
||
])
|
||
.with_deps(vec!["read".into()]),
|
||
);
|
||
}
|
||
(true, false) => {
|
||
// 纯读 → 单个读子任务
|
||
subtasks.push(
|
||
SubTask::new("read", "读取/分析代码")
|
||
.with_tools(vec![
|
||
"read_file".into(),
|
||
"search_files".into(),
|
||
"list_directory".into(),
|
||
])
|
||
.with_deps(vec![]),
|
||
);
|
||
}
|
||
(false, true) => {
|
||
// 纯写 → 单个写子任务
|
||
subtasks.push(
|
||
SubTask::new("write", "修改/生成代码")
|
||
.with_tools(vec![
|
||
"write_file".into(),
|
||
"patch_file".into(),
|
||
"edit_file".into(),
|
||
])
|
||
.with_deps(vec![]),
|
||
);
|
||
}
|
||
(false, false) => {
|
||
// 兜底:按 intent 分配单个默认子任务
|
||
subtasks.push(
|
||
SubTask::new("default", intent.to_string())
|
||
.with_tools(vec![])
|
||
.with_deps(vec![]),
|
||
);
|
||
}
|
||
}
|
||
|
||
let plan = Plan::from_tasks(subtasks.clone());
|
||
DecompositionResult { subtasks, plan }
|
||
}
|
||
|
||
/// 分发执行:按 Plan 分层执行 SubTask(层间串行 + 层内并行)
|
||
///
|
||
/// - 层间串行:上层全部 done 才进下一层(DAG 依赖保证)
|
||
/// - 层内并行:同层 SubTask 用 tokio::task::JoinSet 并发执行
|
||
/// - Token 预算超限时降级为串行(不拒绝执行)
|
||
///
|
||
/// ## 参数
|
||
/// - `plan`: 待执行的 DAG Plan
|
||
/// - `executor`: 子任务执行函数(接收 SubTask + persona_id 字符串)
|
||
/// - `budget`: Token 预算池(None=不限制)
|
||
///
|
||
/// ## 返回值
|
||
/// 按 Plan 原始 tasks 顺序排列的执行结果列表。
|
||
pub async fn dispatch_with_budget<F, Fut>(
|
||
&self,
|
||
plan: &Plan,
|
||
executor: F,
|
||
budget: Option<&Arc<TokenBudgetPool>>,
|
||
) -> Vec<ExecutionResult>
|
||
where
|
||
// 'static + Clone 要求:JoinSet::spawn 需要 owned 闭包(不能借用 self/db)。
|
||
// 调用方需用 Arc 包裹共享状态(db/session)传入闭包。
|
||
// 不需要并行时用 dispatch_serial(无 'static 约束)。
|
||
F: Fn(SubTask, String) -> Fut + Clone + Send + Sync + 'static,
|
||
Fut: std::future::Future<Output = ExecutionResult> + Send,
|
||
{
|
||
let layers = match plan.to_layers() {
|
||
Ok(l) => l,
|
||
Err(_) => {
|
||
tracing::warn!("[COORDINATOR] DAG 分层失败,降级串行");
|
||
let mut results = Vec::new();
|
||
for task in &plan.tasks {
|
||
let persona_id = self.registry.recommend_for_intent(&task.intent).id.clone();
|
||
let result = executor(task.clone(), persona_id).await;
|
||
results.push(result);
|
||
}
|
||
return results;
|
||
}
|
||
};
|
||
|
||
// 预分配每个 SubTask 的 persona_id(避免在 JoinSet 内借用 self)
|
||
let task_persona: std::collections::HashMap<String, String> = plan
|
||
.tasks
|
||
.iter()
|
||
.map(|t| {
|
||
(t.id.clone(), self.registry.recommend_for_intent(&t.intent).id.clone())
|
||
})
|
||
.collect();
|
||
|
||
let mut results_map: std::collections::HashMap<String, ExecutionResult> =
|
||
std::collections::HashMap::new();
|
||
|
||
for (layer_idx, layer) in layers.iter().enumerate() {
|
||
if layer.len() <= 1 {
|
||
for task in layer {
|
||
let pid = task_persona.get(&task.id).cloned().unwrap_or_default();
|
||
let result = executor(task.clone(), pid).await;
|
||
results_map.insert(result.subtask_id.clone(), result);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
let can_parallel = budget
|
||
.map(|b| b.try_reserve(layer.len() as u64 * 10_000)) // 每层按 SubTask 数累加
|
||
.unwrap_or(true);
|
||
|
||
if can_parallel {
|
||
tracing::info!(
|
||
layer = layer_idx,
|
||
count = layer.len(),
|
||
"[COORDINATOR] 层 {} 并行执行 {} 个子任务",
|
||
layer_idx,
|
||
layer.len()
|
||
);
|
||
|
||
let mut join_set: tokio::task::JoinSet<ExecutionResult> =
|
||
tokio::task::JoinSet::new();
|
||
|
||
for task in layer {
|
||
let pid = task_persona.get(&task.id).cloned().unwrap_or_default();
|
||
let exec = executor.clone();
|
||
let task = task.clone();
|
||
join_set.spawn(async move {
|
||
exec(task, pid).await
|
||
});
|
||
}
|
||
|
||
while let Some(join_result) = join_set.join_next().await {
|
||
match join_result {
|
||
Ok(result) => {
|
||
results_map.insert(result.subtask_id.clone(), result);
|
||
}
|
||
Err(e) => {
|
||
tracing::error!("[COORDINATOR] 子任务 panic: {}", e);
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
tracing::info!(
|
||
layer = layer_idx,
|
||
"[COORDINATOR] Token 预算超限,层 {} 降级串行", layer_idx
|
||
);
|
||
for task in layer {
|
||
let pid = task_persona.get(&task.id).cloned().unwrap_or_default();
|
||
let result = executor(task.clone(), pid).await;
|
||
results_map.insert(result.subtask_id.clone(), result);
|
||
}
|
||
}
|
||
}
|
||
|
||
plan.tasks
|
||
.iter()
|
||
.filter_map(|t| results_map.remove(&t.id))
|
||
.collect()
|
||
}
|
||
|
||
/// 串行 dispatch(降级/Phase 1 兼容路径)
|
||
pub async fn dispatch<F, Fut>(&self, plan: &Plan, executor: F) -> Vec<ExecutionResult>
|
||
where
|
||
F: Fn(SubTask, crate::persona::AgentPersona) -> Fut,
|
||
Fut: std::future::Future<Output = ExecutionResult>,
|
||
{
|
||
self.dispatch_serial(plan, &executor).await
|
||
}
|
||
|
||
/// 内部串行执行(无并行)
|
||
async fn dispatch_serial<F, Fut>(&self, plan: &Plan, executor: &F) -> Vec<ExecutionResult>
|
||
where
|
||
F: Fn(SubTask, crate::persona::AgentPersona) -> Fut,
|
||
Fut: std::future::Future<Output = ExecutionResult>,
|
||
{
|
||
let mut results = Vec::new();
|
||
for task in &plan.tasks {
|
||
let persona = self.registry.recommend_for_intent(&task.intent);
|
||
let result = executor(task.clone(), persona.clone()).await;
|
||
results.push(result);
|
||
}
|
||
results
|
||
}
|
||
|
||
/// 合并:汇总子结果 → 合并产出 + 冲突检测
|
||
///
|
||
/// - 成功的 SubTask 产出拼接为 merged_output
|
||
/// - 失败的 SubTask 跳过(不参与合并)
|
||
/// - 冲突检测:多个 SubTask 写同一文件路径 → 标记冲突
|
||
pub fn merge(&self, results: &[ExecutionResult]) -> MergeResult {
|
||
let success_results: Vec<&ExecutionResult> =
|
||
results.iter().filter(|r| r.success).collect();
|
||
|
||
let parts: Vec<String> = success_results
|
||
.iter()
|
||
.map(|r| {
|
||
format!(
|
||
"## SubTask: {} (Persona: {})\n\n{}",
|
||
r.subtask_id, r.persona_id, r.output
|
||
)
|
||
})
|
||
.collect();
|
||
|
||
// 冲突检测:检查是否有多个 SubTask 改了同一文件
|
||
let conflicts = self.detect_file_conflicts(results);
|
||
|
||
MergeResult {
|
||
merged_output: parts.join("\n\n---\n\n"),
|
||
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(", ")
|
||
),
|
||
subtask_a: subtasks.first().cloned(),
|
||
subtask_b: subtasks.get(1).cloned(),
|
||
recommendation: None,
|
||
});
|
||
}
|
||
}
|
||
conflicts
|
||
}
|
||
|
||
/// 仲裁冲突:对每个冲突给出推荐解决方案
|
||
///
|
||
/// Phase 1:规则驱动(简单启发式)
|
||
/// - 只有一方成功的冲突 → 推荐成功方
|
||
/// - 两方都成功 → 推荐 Merged(需人工确认)
|
||
/// - 无法判断 → Manual(留给用户)
|
||
///
|
||
/// Phase 2 预留:LLM 驱动(读 diff → 推荐方案 + 理由)
|
||
pub fn arbitrate_conflicts(
|
||
&self,
|
||
conflicts: &mut [ConflictItem],
|
||
results: &[ExecutionResult],
|
||
) {
|
||
let success_ids: std::collections::HashSet<&str> =
|
||
results.iter().filter(|r| r.success).map(|r| r.subtask_id.as_str()).collect();
|
||
|
||
for c in conflicts.iter_mut() {
|
||
let a_ok = c.subtask_a.as_deref().map(|id| success_ids.contains(id)).unwrap_or(false);
|
||
let b_ok = c.subtask_b.as_deref().map(|id| success_ids.contains(id)).unwrap_or(false);
|
||
|
||
c.recommendation = Some(match (a_ok, b_ok) {
|
||
(true, false) => ConflictResolution::AcceptA,
|
||
(false, true) => ConflictResolution::AcceptB,
|
||
(true, true) => ConflictResolution::Merged,
|
||
(false, false) => ConflictResolution::Manual,
|
||
});
|
||
|
||
tracing::info!(
|
||
file = %c.file,
|
||
recommendation = ?c.recommendation,
|
||
"[REVIEWER] 冲突仲裁推荐"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 从执行产出文本中提取被写入的文件路径(简化检测:匹配 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
|
||
}
|
||
|
||
// ---- 单元测试 ---------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::persona::{PersonaRegistry, PERSONA_CODER};
|
||
|
||
fn make_coord() -> Coordinator {
|
||
Coordinator::new(PersonaRegistry::new())
|
||
}
|
||
|
||
// -- decompose 路径覆盖 --
|
||
|
||
#[test]
|
||
fn decompose_read_then_write_chinese() {
|
||
let coord = make_coord();
|
||
let result = coord.decompose("modify", "请读取当前代码,然后修改它");
|
||
|
||
assert_eq!(result.subtasks.len(), 2, "读+写应拆为两个子任务");
|
||
assert_eq!(result.subtasks[0].id, "read", "第一个子任务应为读");
|
||
assert_eq!(result.subtasks[1].id, "write", "第二个子任务应为写");
|
||
assert!(
|
||
result.subtasks[1].deps.contains(&"read".to_string()),
|
||
"写子任务应依赖读子任务"
|
||
);
|
||
assert_eq!(result.plan.len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn decompose_read_then_write_english() {
|
||
let coord = make_coord();
|
||
let result = coord.decompose("modify", "read the file and then write changes");
|
||
|
||
assert_eq!(result.subtasks.len(), 2);
|
||
assert_eq!(result.subtasks[0].id, "read");
|
||
assert_eq!(result.subtasks[1].id, "write");
|
||
assert!(result.subtasks[1].deps.contains(&"read".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn decompose_read_only_chinese() {
|
||
let coord = make_coord();
|
||
let result = coord.decompose("review", "帮我查看这段代码有什么问题");
|
||
|
||
assert_eq!(result.subtasks.len(), 1);
|
||
assert_eq!(result.subtasks[0].id, "read");
|
||
}
|
||
|
||
#[test]
|
||
fn decompose_read_only_search() {
|
||
let coord = make_coord();
|
||
let result = coord.decompose("search", "搜索项目中的 TODO 注释");
|
||
|
||
assert_eq!(result.subtasks.len(), 1);
|
||
assert_eq!(result.subtasks[0].id, "read");
|
||
}
|
||
|
||
#[test]
|
||
fn decompose_write_only_generate() {
|
||
let coord = make_coord();
|
||
let result = coord.decompose("implement", "请生成一个 Rust 模块");
|
||
|
||
assert_eq!(result.subtasks.len(), 1);
|
||
assert_eq!(result.subtasks[0].id, "write");
|
||
}
|
||
|
||
#[test]
|
||
fn decompose_write_only_fix() {
|
||
let coord = make_coord();
|
||
let result = coord.decompose("fix", "修复这个 bug");
|
||
|
||
assert_eq!(result.subtasks.len(), 1);
|
||
assert_eq!(result.subtasks[0].id, "write");
|
||
}
|
||
|
||
#[test]
|
||
fn decompose_fallback_default() {
|
||
let coord = make_coord();
|
||
let result = coord.decompose("chat", "你好,今天天气怎么样");
|
||
|
||
assert_eq!(result.subtasks.len(), 1);
|
||
assert_eq!(result.subtasks[0].id, "default");
|
||
}
|
||
|
||
#[test]
|
||
fn decompose_create_keyword() {
|
||
let coord = make_coord();
|
||
let result = coord.decompose("create", "创建新的 API 端点");
|
||
|
||
assert_eq!(result.subtasks.len(), 1);
|
||
assert_eq!(result.subtasks[0].id, "write");
|
||
}
|
||
|
||
// -- dispatch --
|
||
|
||
#[test]
|
||
fn dispatch_serial_execution() {
|
||
let coord = make_coord();
|
||
let plan = Plan::from_tasks(vec![
|
||
SubTask::new("read", "read code").with_deps(vec![]),
|
||
SubTask::new("write", "write code").with_deps(vec!["read".into()]),
|
||
]);
|
||
|
||
let results = futures::executor::block_on(coord.dispatch(&plan, |task, persona| {
|
||
async move {
|
||
ExecutionResult {
|
||
subtask_id: task.id,
|
||
persona_id: persona.id,
|
||
output: format!("执行 {} 完毕", task.intent),
|
||
success: true,
|
||
}
|
||
}
|
||
}));
|
||
|
||
assert_eq!(results.len(), 2);
|
||
assert!(results[0].success);
|
||
assert!(results[1].success);
|
||
assert_eq!(results[0].subtask_id, "read");
|
||
assert_eq!(results[1].subtask_id, "write");
|
||
assert_eq!(results[0].persona_id, PERSONA_CODER);
|
||
}
|
||
|
||
#[test]
|
||
fn dispatch_empty_plan() {
|
||
let coord = make_coord();
|
||
let plan = Plan::new();
|
||
|
||
let results = futures::executor::block_on(coord.dispatch(&plan, |task, persona| {
|
||
async move {
|
||
ExecutionResult {
|
||
subtask_id: task.id,
|
||
persona_id: persona.id,
|
||
output: String::new(),
|
||
success: true,
|
||
}
|
||
}
|
||
}));
|
||
|
||
assert!(results.is_empty(), "空 Plan 应产生空结果");
|
||
}
|
||
|
||
#[test]
|
||
fn dispatch_persona_assigned_by_intent() {
|
||
let coord = make_coord();
|
||
let plan = Plan::from_tasks(vec![
|
||
SubTask::new("code", "implement feature").with_deps(vec![]),
|
||
]);
|
||
|
||
let results = futures::executor::block_on(coord.dispatch(&plan, |task, persona| {
|
||
async move {
|
||
ExecutionResult {
|
||
subtask_id: task.id,
|
||
persona_id: persona.id,
|
||
output: String::new(),
|
||
success: true,
|
||
}
|
||
}
|
||
}));
|
||
|
||
// "implement" 不触发特殊人设 → 默认 coder
|
||
assert_eq!(results[0].persona_id, PERSONA_CODER);
|
||
}
|
||
|
||
// -- merge --
|
||
|
||
#[test]
|
||
fn merge_single_result() {
|
||
let coord = make_coord();
|
||
let results = vec![ExecutionResult {
|
||
subtask_id: "read".into(),
|
||
persona_id: "reviewer".into(),
|
||
output: "分析完成,发现 3 个问题".into(),
|
||
success: true,
|
||
}];
|
||
|
||
let merged = coord.merge(&results);
|
||
assert!(merged.merged_output.contains("分析完成"));
|
||
assert!(merged.conflicts.is_empty(), "Phase 1 不应有冲突");
|
||
}
|
||
|
||
#[test]
|
||
fn merge_multiple_results() {
|
||
let coord = make_coord();
|
||
let results = vec![
|
||
ExecutionResult {
|
||
subtask_id: "read".into(),
|
||
persona_id: "reviewer".into(),
|
||
output: "代码分析结果".into(),
|
||
success: true,
|
||
},
|
||
ExecutionResult {
|
||
subtask_id: "write".into(),
|
||
persona_id: "coder".into(),
|
||
output: "修改后的代码".into(),
|
||
success: true,
|
||
},
|
||
];
|
||
|
||
let merged = coord.merge(&results);
|
||
assert!(merged.merged_output.contains("代码分析结果"));
|
||
assert!(merged.merged_output.contains("修改后的代码"));
|
||
assert!(merged.merged_output.contains("SubTask: read"));
|
||
assert!(merged.merged_output.contains("SubTask: write"));
|
||
assert!(merged.conflicts.is_empty(), "Phase 1 不应有冲突");
|
||
}
|
||
|
||
#[test]
|
||
fn merge_empty_results() {
|
||
let coord = make_coord();
|
||
let merged = coord.merge(&[]);
|
||
assert!(merged.merged_output.is_empty(), "空输入应产生空输出");
|
||
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());
|
||
}
|
||
|
||
// -- Reviewer 仲裁 --
|
||
|
||
#[test]
|
||
fn arb_01_both_success_recommends_merged() {
|
||
let coord = make_coord();
|
||
let mut conflicts = vec![ConflictItem {
|
||
file: "main.rs".into(),
|
||
description: "冲突".into(),
|
||
subtask_a: Some("A".into()),
|
||
subtask_b: Some("B".into()),
|
||
recommendation: None,
|
||
}];
|
||
let results = vec![
|
||
ExecutionResult { subtask_id: "A".into(), persona_id: "coder".into(), output: "".into(), success: true },
|
||
ExecutionResult { subtask_id: "B".into(), persona_id: "coder".into(), output: "".into(), success: true },
|
||
];
|
||
coord.arbitrate_conflicts(&mut conflicts, &results);
|
||
assert_eq!(conflicts[0].recommendation, Some(ConflictResolution::Merged));
|
||
}
|
||
|
||
#[test]
|
||
fn arb_02_a_failed_recommends_b() {
|
||
let coord = make_coord();
|
||
let mut conflicts = vec![ConflictItem {
|
||
file: "main.rs".into(),
|
||
description: "冲突".into(),
|
||
subtask_a: Some("A".into()),
|
||
subtask_b: Some("B".into()),
|
||
recommendation: None,
|
||
}];
|
||
let results = vec![
|
||
ExecutionResult { subtask_id: "A".into(), persona_id: "coder".into(), output: "".into(), success: false },
|
||
ExecutionResult { subtask_id: "B".into(), persona_id: "coder".into(), output: "".into(), success: true },
|
||
];
|
||
coord.arbitrate_conflicts(&mut conflicts, &results);
|
||
assert_eq!(conflicts[0].recommendation, Some(ConflictResolution::AcceptB));
|
||
}
|
||
|
||
#[test]
|
||
fn arb_03_both_failed_recommends_manual() {
|
||
let coord = make_coord();
|
||
let mut conflicts = vec![ConflictItem {
|
||
file: "main.rs".into(),
|
||
description: "冲突".into(),
|
||
subtask_a: Some("A".into()),
|
||
subtask_b: Some("B".into()),
|
||
recommendation: None,
|
||
}];
|
||
let results = vec![
|
||
ExecutionResult { subtask_id: "A".into(), persona_id: "coder".into(), output: "".into(), success: false },
|
||
ExecutionResult { subtask_id: "B".into(), persona_id: "coder".into(), output: "".into(), success: false },
|
||
];
|
||
coord.arbitrate_conflicts(&mut conflicts, &results);
|
||
assert_eq!(conflicts[0].recommendation, Some(ConflictResolution::Manual));
|
||
}
|
||
|
||
// -- DispatchStrategy --
|
||
|
||
#[test]
|
||
fn dispatch_strategy_name() {
|
||
assert_eq!(DispatchStrategy::RuleBased.name(), "rule_based");
|
||
assert_eq!(DispatchStrategy::LLMDriven.name(), "llm_driven");
|
||
}
|
||
|
||
// -- Edge cases: 关键词混合边界 --
|
||
|
||
#[test]
|
||
fn decompose_review_triggers_read() {
|
||
let coord = make_coord();
|
||
// "review" 同时触发读关键词
|
||
let result = coord.decompose("review", "review this PR for me");
|
||
assert_eq!(result.subtasks.len(), 1);
|
||
assert_eq!(result.subtasks[0].id, "read");
|
||
}
|
||
|
||
#[test]
|
||
fn decompose_create_and_review_triggers_both() {
|
||
let coord = make_coord();
|
||
let result = coord.decompose("implement", "请先分析代码结构然后创建新模块");
|
||
assert_eq!(
|
||
result.subtasks.len(),
|
||
2,
|
||
"含分析和创建关键词应拆为两个子任务"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn decompose_plan_not_empty() {
|
||
let coord = make_coord();
|
||
let result = coord.decompose("chat", "随便聊聊");
|
||
assert!(!result.plan.is_empty(), "兜底场景也应有 Plan");
|
||
}
|
||
|
||
#[test]
|
||
fn decompose_result_holds_plan_consistent() {
|
||
let coord = make_coord();
|
||
let result = coord.decompose("modify", "读取配置文件然后更新它");
|
||
assert_eq!(
|
||
result.subtasks.len(),
|
||
result.plan.len(),
|
||
"DecompositionResult 的 subtasks 应与 plan 一致"
|
||
);
|
||
for st in &result.subtasks {
|
||
assert!(
|
||
result.plan.tasks.iter().any(|t| t.id == st.id),
|
||
"plan 应包含所有 subtask: {}",
|
||
st.id
|
||
);
|
||
}
|
||
}
|
||
|
||
// -- Token 预算池 --
|
||
|
||
#[test]
|
||
fn tok_01_budget_sufficient() {
|
||
let pool = TokenBudgetPool::new(100_000);
|
||
assert!(pool.try_reserve(10_000), "预算充足时应允许");
|
||
assert_eq!(pool.consumed(), 10_000);
|
||
assert!(pool.try_reserve(50_000), "剩余充足时应允许");
|
||
assert_eq!(pool.consumed(), 60_000);
|
||
}
|
||
|
||
#[test]
|
||
fn tok_02_budget_exceeded() {
|
||
let pool = TokenBudgetPool::new(100_000);
|
||
assert!(pool.try_reserve(98_000));
|
||
assert!(!pool.try_reserve(5_000), "超限应拒绝");
|
||
assert_eq!(pool.consumed(), 98_000, "拒绝后 consumed 不增");
|
||
}
|
||
|
||
#[test]
|
||
fn tok_03_concurrent_reserve() {
|
||
let pool = TokenBudgetPool::new(15_000);
|
||
// 模拟两个并发申请各 10k,总额 20k > 15k,只有第一个应成功
|
||
let pool1 = pool.clone();
|
||
let pool2 = pool.clone();
|
||
let r1 = pool1.try_reserve(10_000);
|
||
let r2 = pool2.try_reserve(10_000);
|
||
// 至少一个成功一个失败(顺序由调度决定)
|
||
assert!(r1 || r2, "至少一个成功");
|
||
assert!(!r1 || !r2, "不能两个都成功(总超预算)");
|
||
}
|
||
|
||
#[test]
|
||
fn tok_04_zero_means_unlimited() {
|
||
let pool = TokenBudgetPool::new(0);
|
||
assert!(pool.try_reserve(999_999_999), "total=0 不限制");
|
||
}
|
||
|
||
// -- 并行调度 --
|
||
|
||
#[tokio::test]
|
||
async fn par_01_diamond_dependency() {
|
||
// A → {B, C} → D,B/C 同层应并行,D 等 B+C 都 done
|
||
let coord = make_coord();
|
||
let plan = Plan::from_tasks(vec![
|
||
SubTask::new("A", "base"),
|
||
SubTask::new("B", "branch1").with_deps(vec!["A".into()]),
|
||
SubTask::new("C", "branch2").with_deps(vec!["A".into()]),
|
||
SubTask::new("D", "final").with_deps(vec!["B".into(), "C".into()]),
|
||
]);
|
||
|
||
let results = coord.dispatch_with_budget(&plan, |task, persona_id: String| {
|
||
async move {
|
||
ExecutionResult {
|
||
subtask_id: task.id,
|
||
persona_id,
|
||
output: format!("executed {}", task.intent),
|
||
success: true,
|
||
}
|
||
}
|
||
}, None).await;
|
||
|
||
assert_eq!(results.len(), 4, "全部 4 个子任务应有结果");
|
||
// 结果按 plan.tasks 原始顺序
|
||
assert_eq!(results[0].subtask_id, "A");
|
||
assert_eq!(results[1].subtask_id, "B");
|
||
assert_eq!(results[2].subtask_id, "C");
|
||
assert_eq!(results[3].subtask_id, "D");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn par_02_parallel_layer_executed() {
|
||
// 3 个无依赖任务应在同层并行执行
|
||
let coord = make_coord();
|
||
let plan = Plan::from_tasks(vec![
|
||
SubTask::new("t1", "read file1"),
|
||
SubTask::new("t2", "read file2"),
|
||
SubTask::new("t3", "read file3"),
|
||
]);
|
||
|
||
let results = coord.dispatch_with_budget(&plan, |task, persona_id: String| {
|
||
async move {
|
||
ExecutionResult {
|
||
subtask_id: task.id.clone(),
|
||
persona_id,
|
||
output: format!("done {}", task.id),
|
||
success: true,
|
||
}
|
||
}
|
||
}, None).await;
|
||
|
||
assert_eq!(results.len(), 3);
|
||
for r in &results {
|
||
assert!(r.success, "所有子任务应成功");
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn par_03_budget_exceeded_degrades_to_serial() {
|
||
// Token 预算仅够 1 个并行,第 2 个应降级串行(不拒绝)
|
||
let coord = make_coord();
|
||
let plan = Plan::from_tasks(vec![
|
||
SubTask::new("t1", "task1"),
|
||
SubTask::new("t2", "task2"),
|
||
SubTask::new("t3", "task3"),
|
||
]);
|
||
let pool = TokenBudgetPool::new(5_000); // 不够 3×10k
|
||
|
||
let results = coord.dispatch_with_budget(&plan, |task, persona_id: String| {
|
||
async move {
|
||
ExecutionResult {
|
||
subtask_id: task.id,
|
||
persona_id,
|
||
output: String::new(),
|
||
success: true,
|
||
}
|
||
}
|
||
}, Some(&pool)).await;
|
||
|
||
// 预算超限应降级串行,不拒绝执行
|
||
assert_eq!(results.len(), 3, "降级串行也应全部执行");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn par_04_single_task_layer_serial() {
|
||
// 线性链(每层 1 任务)不走并行路径
|
||
let coord = make_coord();
|
||
let plan = Plan::from_tasks(vec![
|
||
SubTask::new("a", "step1"),
|
||
SubTask::new("b", "step2").with_deps(vec!["a".into()]),
|
||
]);
|
||
|
||
let results = coord.dispatch_with_budget(&plan, |task, persona_id: String| {
|
||
async move {
|
||
ExecutionResult {
|
||
subtask_id: task.id,
|
||
persona_id,
|
||
output: String::new(),
|
||
success: true,
|
||
}
|
||
}
|
||
}, None).await;
|
||
|
||
assert_eq!(results.len(), 2);
|
||
assert_eq!(results[0].subtask_id, "a");
|
||
assert_eq!(results[1].subtask_id, "b");
|
||
}
|
||
}
|