feat: 3.2.17中途变更处理(git驱动) + 3.2.9a Worker git强制 + 3.2.5 Phase7 squash merge + Arc git初始化检测

- 新增 ChangeClassifier(爆炸半径分类:IMPLEMENTATION/INTERFACE/GLOBAL_CONSTRAINT)
- 新增 ImpactPropagator(BFS影响传播:IMPACTED/BOUNDARY/SAFE差异化标记)
- task_graph.py:invalidate_by_adr()差异化失效 + CascadeReport扩展字段(向后兼容)
- eng_mode.py:三阶段差异化流程(分类→传播→失效→git操作→验证任务→重规划)
- eng_mode.py:_git_squash_merge_and_tag() + Phase 7 集成
- do_mode.py:_ensure_all_committed() Worker git操作强制
- adr_watcher.py:内容快照 + get_content_for_classification()
- events.py:ADR_CLASSIFIED/IMPACT_PROPAGATED/BOUNDARY_VERIFICATION_GENERATED
- partial_replanner.py:replan_with_constraints() + generate_verification_tasks()
- project_bootstrap.py:ensure_git_initialized() Arc规划前检测
- test_p1_21_phase2.py:21个新测试,95个全量测试0失败

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
AirPlan
2026-06-15 14:45:41 +08:00
parent a60d1a0c04
commit 9702f1b186
11 changed files with 1351 additions and 82 deletions

View File

@@ -0,0 +1,104 @@
"""
BFS 影响传播 — P1-21 / 3.2.17 Phase 1 组件。
根据 BlastRadius 在任务 DAG 中传播影响,标记每个任务为 IMPACTED / BOUNDARY / SAFE。
"""
from __future__ import annotations
import enum
from collections import deque
from dataclasses import dataclass, field
from air_runtime.change_classifier import BlastRadius
from air_runtime.task_graph import TaskGraph
class ImpactLabel(enum.Enum):
IMPACTED = "impacted"
BOUNDARY = "boundary"
SAFE = "safe"
@dataclass
class PropagationResult:
impacted: list[str] = field(default_factory=list)
boundary: list[str] = field(default_factory=list)
safe: list[str] = field(default_factory=list)
class ImpactPropagator:
"""BFS 影响传播,基于 BlastRadius 差异化标记。
传播规则:
- IMPLEMENTATION: 仅直接引用 ADR 的任务 → IMPACTED其他 → SAFE不下游传播
- INTERFACE: 直接引用 → IMPACTED下游 1 跳邻居 → BOUNDARY其他 → SAFE
- GLOBAL_CONSTRAINT: 全图 → IMPACTED
结果存储在 TaskNode.meta["impact_label"],不修改 status 字段(正交)。
"""
_META_KEY = "impact_label"
def propagate(self, graph: TaskGraph, adr_id: str,
blast_radius: BlastRadius) -> PropagationResult:
self.clear_labels(graph)
tasks_ref_adr = [n.id for n in graph.nodes.values() if adr_id in n.adr_refs]
all_ids = set(graph.nodes.keys())
if blast_radius == BlastRadius.GLOBAL_CONSTRAINT:
for nid in all_ids:
graph.nodes[nid].meta[self._META_KEY] = ImpactLabel.IMPACTED.value
return PropagationResult(
impacted=list(all_ids), boundary=[], safe=[],
)
if blast_radius == BlastRadius.IMPLEMENTATION:
impacted = list(tasks_ref_adr)
safe = [nid for nid in all_ids if nid not in impacted]
for nid in impacted:
graph.nodes[nid].meta[self._META_KEY] = ImpactLabel.IMPACTED.value
for nid in safe:
graph.nodes[nid].meta[self._META_KEY] = ImpactLabel.SAFE.value
return PropagationResult(impacted=impacted, boundary=[], safe=safe)
# INTERFACE: direct refs → IMPACTED, 1-hop downstream → BOUNDARY, rest → SAFE
impacted = set(tasks_ref_adr)
# BFS 收集下游 1 跳邻居(仅从 IMPACTED 出发,排除自身)
boundary = set()
for nid in list(impacted):
node = graph.nodes.get(nid)
if node:
for target in node.out_edges:
if target not in impacted:
boundary.add(target)
safe = all_ids - impacted - boundary
for nid in impacted:
graph.nodes[nid].meta[self._META_KEY] = ImpactLabel.IMPACTED.value
for nid in boundary:
graph.nodes[nid].meta[self._META_KEY] = ImpactLabel.BOUNDARY.value
for nid in safe:
graph.nodes[nid].meta[self._META_KEY] = ImpactLabel.SAFE.value
return PropagationResult(
impacted=list(impacted), boundary=list(boundary), safe=list(safe),
)
def impacted_tasks(self, graph: TaskGraph) -> list[str]:
return [nid for nid, n in graph.nodes.items()
if n.meta.get(self._META_KEY) == ImpactLabel.IMPACTED.value]
def boundary_tasks(self, graph: TaskGraph) -> list[str]:
return [nid for nid, n in graph.nodes.items()
if n.meta.get(self._META_KEY) == ImpactLabel.BOUNDARY.value]
def safe_tasks(self, graph: TaskGraph) -> list[str]:
return [nid for nid, n in graph.nodes.items()
if n.meta.get(self._META_KEY) == ImpactLabel.SAFE.value]
def clear_labels(self, graph: TaskGraph) -> None:
for node in graph.nodes.values():
node.meta.pop(self._META_KEY, None)