Files
AirPlan-V2/lib/air_runtime/worktree.py
AirLongDian e73a4da354 feat: P1-19 P1-20 实现 - 边界测试强制 + 高风险审计 + UI Skill 路由
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>
2026-06-10 17:06:48 +08:00

158 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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