P1-19.1: Arc 边界测试强制 - TaskNode 新增 test_required 字段 - _inject_boundary_tests() 为每个模块注入接口测试和单元测试任务 - Done When 验证必须包含"测试通过" P1-19.2: AirRvr 高风险审计 - 新增 HighRiskAudit, HighRiskFinding 数据类 - ReviewReport 新增 highRiskAudit 字段,含 lifecycle/nullPointer/danglingPointer/exceptionSafety/concurrency + overallRisk + deliveryVerdict - 序列化/反序列化支持 P1-19.3: block-release 集成 - dispatch_worker_group() 派发前扫描最新审查报告 - deliveryVerdict=block-release 时阻止所有后续派发 - 记录 eng.blocked 事件 P1-20: frontend-design Skill 集成 - is_ui_task() UI 任务检测 - ensure_frontend_design_skill() 自动安装 Skill - route_ui_task() UI 任务路由决策 - enter_worker() 集成 UI 检测,skill 不可用时阻止执行 - commands/do.md 更新 UI 处理说明 - SKILL.md 新增 INV-12/INV-13/INV-14 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
158 lines
5.8 KiB
Python
158 lines
5.8 KiB
Python
"""
|
||
Worktree 隔离并行模块 — V2 P1-15 修复。
|
||
区域级冲突检测 + git worktree 隔离,允许同文件不同区域任务安全并行。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import enum
|
||
import subprocess
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
|
||
class ConflictLevel(enum.Enum):
|
||
NONE = "none" # 无文件重叠,直接并行
|
||
SOFT = "soft" # 同文件不同区域,worktree 隔离并行
|
||
HARD = "hard" # 同文件同区域,必须串行
|
||
|
||
|
||
@dataclass
|
||
class Conflict:
|
||
task_a: str
|
||
task_b: str
|
||
level: ConflictLevel # NONE / SOFT / HARD
|
||
reason: str = ""
|
||
|
||
def to_dict(self) -> dict:
|
||
return {
|
||
"taskA": self.task_a,
|
||
"taskB": self.task_b,
|
||
"level": self.level.value,
|
||
"reason": self.reason,
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class MergeResult:
|
||
task_id: str
|
||
successful: bool
|
||
conflicts: list[str]
|
||
|
||
|
||
class RegionConflictDetector:
|
||
"""区域级写集冲突检测。
|
||
|
||
区域定义(按优先级):
|
||
1. todo.md 中的区域标注 [region: xxx]
|
||
2. 函数/类边界(AST 分析或标记注释)
|
||
3. 行号区间(从 diff 或任务元数据获取)
|
||
"""
|
||
|
||
def detect(self, task_a_write_set: list[str], task_b_write_set: list[str],
|
||
regions_a: dict[str, list[tuple[int, int]]] | None = None,
|
||
regions_b: dict[str, list[tuple[int, int]]] | None = None) -> ConflictLevel:
|
||
file_overlap = set(task_a_write_set) & set(task_b_write_set)
|
||
if not file_overlap:
|
||
return ConflictLevel.NONE
|
||
|
||
if regions_a is None or regions_b is None:
|
||
return ConflictLevel.SOFT
|
||
|
||
for fpath in file_overlap:
|
||
ra = regions_a.get(fpath, [(0, 999999)])
|
||
rb = regions_b.get(fpath, [(0, 999999)])
|
||
for a_start, a_end in ra:
|
||
for b_start, b_end in rb:
|
||
if a_start < b_end and b_start < a_end:
|
||
return ConflictLevel.HARD
|
||
return ConflictLevel.SOFT
|
||
|
||
def detect_batch(self, task_write_sets: dict[str, list[str]],
|
||
task_regions: dict[str, dict[str, list[tuple[int, int]]]] | None = None,
|
||
) -> list[Conflict]:
|
||
"""批量检测:接收 {task_id: [file, ...]} 映射,返回所有冲突对。
|
||
|
||
逐对调用 detect(),仅返回 level != NONE 的冲突。
|
||
"""
|
||
conflicts: list[Conflict] = []
|
||
task_ids = list(task_write_sets.keys())
|
||
for i in range(len(task_ids)):
|
||
for j in range(i + 1, len(task_ids)):
|
||
a, b = task_ids[i], task_ids[j]
|
||
ra = task_regions.get(a) if task_regions else None
|
||
rb = task_regions.get(b) if task_regions else None
|
||
level = self.detect(task_write_sets[a], task_write_sets[b], ra, rb)
|
||
if level != ConflictLevel.NONE:
|
||
overlap = sorted(set(task_write_sets[a]) & set(task_write_sets[b]))
|
||
reason = f"file overlap: {', '.join(overlap)}" if overlap else ""
|
||
if level == ConflictLevel.HARD:
|
||
reason = f"region overlap: {', '.join(overlap)}" if overlap else "same region"
|
||
conflicts.append(Conflict(task_a=a, task_b=b, level=level, reason=reason))
|
||
return conflicts
|
||
|
||
def extract_regions_from_task(self, file_path: Path, region_markers: list[tuple[int, int]]) -> dict[str, list[tuple[int, int]]]:
|
||
"""从任务的区域标注提取行号区间。"""
|
||
return {str(file_path): region_markers}
|
||
|
||
|
||
class WorktreeIsolation:
|
||
"""为 SOFT 冲突任务创建 git worktree 隔离,完成后合并回主分支。"""
|
||
|
||
def __init__(self, repo_root: Path):
|
||
self._repo_root = repo_root
|
||
|
||
def create_worktree(self, task_id: str, base_ref: str = "HEAD") -> Path:
|
||
branch = f"air-{task_id}"
|
||
wt_path = self._repo_root.parent / f"{self._repo_root.name}-air-{task_id}"
|
||
|
||
subprocess.run(
|
||
["git", "-C", str(self._repo_root), "worktree", "add", "-b", branch, str(wt_path), base_ref],
|
||
check=True, capture_output=True, text=True,
|
||
)
|
||
return wt_path
|
||
|
||
def merge_back(self, task_id: str, wt_path: Path) -> MergeResult:
|
||
branch = f"air-{task_id}"
|
||
try:
|
||
subprocess.run(
|
||
["git", "-C", str(self._repo_root), "merge", "--no-ff", branch],
|
||
check=True, capture_output=True, text=True,
|
||
)
|
||
return MergeResult(task_id=task_id, successful=True, conflicts=[])
|
||
except subprocess.CalledProcessError as exc:
|
||
conflicts = self._parse_conflicts(exc.stderr)
|
||
self._abort_merge()
|
||
return MergeResult(task_id=task_id, successful=False, conflicts=conflicts)
|
||
|
||
def cleanup(self, task_id: str, wt_path: Path) -> None:
|
||
branch = f"air-{task_id}"
|
||
try:
|
||
subprocess.run(
|
||
["git", "-C", str(self._repo_root), "worktree", "remove", str(wt_path), "--force"],
|
||
check=True, capture_output=True, text=True,
|
||
)
|
||
subprocess.run(
|
||
["git", "-C", str(self._repo_root), "branch", "-D", branch],
|
||
check=True, capture_output=True, text=True,
|
||
)
|
||
except subprocess.CalledProcessError:
|
||
pass
|
||
|
||
def _abort_merge(self) -> None:
|
||
try:
|
||
subprocess.run(
|
||
["git", "-C", str(self._repo_root), "merge", "--abort"],
|
||
check=True, capture_output=True, text=True,
|
||
)
|
||
except subprocess.CalledProcessError:
|
||
pass
|
||
|
||
@staticmethod
|
||
def _parse_conflicts(stderr: str) -> list[str]:
|
||
conflicts: list[str] = []
|
||
for line in stderr.splitlines():
|
||
if "CONFLICT" in line:
|
||
conflicts.append(line.strip())
|
||
return conflicts
|