Files
AirPlan-V2/lib/air_runtime/task_graph.py
AirPlan 9702f1b186 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>
2026-06-15 14:45:41 +08:00

413 lines
17 KiB
Python
Executable File
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.
"""
动态任务依赖图DAG— V2 P1-14 修复。
替代 V1 静态 todo.md 表格,支持 Arc 增量重规划Eng 增量吸收。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class TaskNode:
id: str
status: str = "TODO" # TODO | DISPATCHED | DONE | BLOCKED | INVALIDATED
task: str = ""
files_dirs: str = ""
done_when: str = ""
in_degree: int = 0
out_edges: list[str] = field(default_factory=list)
write_set: list[str] = field(default_factory=list)
meta: dict[str, Any] = field(default_factory=dict)
test_required: bool = False # P1-19.1: 边界测试强制标记
adr_refs: list[str] = field(default_factory=list) # P1-21: ADR→任务溯源链
keep_constraints: list[str] = field(default_factory=list) # P1-24: 保留约束(不可修改的文件/目录)
@dataclass
class Edge:
source: str
target: str
kind: str = "dependency" # dependency | conflict | sync
@dataclass
class EdgeChange:
added: list[Edge] = field(default_factory=list)
removed: list[Edge] = field(default_factory=list)
@dataclass
class CascadeReport:
"""P1-21: ADR 变更级联失效报告。3.2.17 差异化处理后新增 boundary/impact 字段。"""
invalidated_completed: int = 0
terminated_in_progress: int = 0
cascaded_downstream: int = 0
rollback_ref: str = ""
invalidated_task_ids: list[str] = field(default_factory=list)
boundary_task_ids: list[str] = field(default_factory=list)
blast_radius: str = "" # "implementation" | "interface" | "global_constraint"
@dataclass
class PlanDelta:
"""Arc 重规划产出的增量差异,替代全量覆盖 todo.md。"""
removed_tasks: list[str] = field(default_factory=list)
added_tasks: list[TaskNode] = field(default_factory=list)
modified_tasks: list[TaskNode] = field(default_factory=list)
edge_changes: EdgeChange = field(default_factory=EdgeChange)
rollback_ref: str = "" # P1-21: 回滚快照引用
class TaskGraph:
"""动态任务依赖图,支持增量更新和全量替换。"""
def __init__(self):
self.nodes: dict[str, TaskNode] = {}
self.edges: list[Edge] = []
self.dispatch_frozen: bool = False # P1-21: 调度冻结
def add_node(self, node: TaskNode) -> None:
self.nodes[node.id] = node
def add_edge(self, edge: Edge) -> None:
self.edges.append(edge)
if edge.target in self.nodes:
self.nodes[edge.target].in_degree += 1
if edge.source in self.nodes:
self.nodes[edge.source].out_edges.append(edge.target)
def apply_delta(self, delta: PlanDelta) -> None:
"""增量吸收 Arc 的重规划结果,保留已调度任务不受影响。"""
for task_id in delta.removed_tasks:
self._remove_node(task_id)
for node in delta.added_tasks:
self._add_node(node)
for node in delta.modified_tasks:
self._update_node(node)
for edge in delta.edge_changes.removed:
self._remove_edge(edge)
for edge in delta.edge_changes.added:
self._add_edge(edge)
def apply_full_replace(self, nodes: list[TaskNode], edges: list[Edge]) -> None:
"""全量替换模式Arc 产出完整 DAG保留已完成任务状态。
INVALIDATED 状态不保留(已被级联失效标记的任务在全量替换时重置)。"""
done_status = {tid: n.status for tid, n in self.nodes.items()
if n.status in ("DONE", "DISPATCHED")}
self.nodes = {n.id: n for n in nodes}
self.edges = list(edges)
for tid, status in done_status.items():
if tid in self.nodes:
self.nodes[tid].status = status
for edge in self.edges:
if edge.target in self.nodes:
self.nodes[edge.target].in_degree += 1
if edge.source in self.nodes:
self.nodes[edge.source].out_edges.append(edge.target)
def ready_tasks(self) -> list[str]:
"""返回当前入度为 0 且状态为 TODO 的任务。调度冻结时返回空。"""
if self.dispatch_frozen:
return []
return [nid for nid, n in self.nodes.items() if n.in_degree == 0 and n.status == "TODO"]
def diff(self, other: TaskGraph) -> PlanDelta:
"""对比自身与 other产出 PlanDeltaadd/remove/modify node + edge changes
self = 新图, other = 旧图before replan
"""
delta = PlanDelta()
old_ids = set(other.nodes.keys())
new_ids = set(self.nodes.keys())
# 移除
delta.removed_tasks = list(old_ids - new_ids)
# 新增
delta.added_tasks = [self.nodes[tid] for tid in (new_ids - old_ids)]
# 修改
for tid in old_ids & new_ids:
old_n = other.nodes[tid]
new_n = self.nodes[tid]
if (old_n.task != new_n.task
or old_n.files_dirs != new_n.files_dirs
or old_n.done_when != new_n.done_when
or old_n.write_set != new_n.write_set):
delta.modified_tasks.append(new_n)
# Edge 差异
old_edges = {(e.source, e.target, e.kind) for e in other.edges}
new_edges = {(e.source, e.target, e.kind) for e in self.edges}
for s, t, k in (new_edges - old_edges):
delta.edge_changes.added.append(Edge(source=s, target=t, kind=k))
for s, t, k in (old_edges - new_edges):
delta.edge_changes.removed.append(Edge(source=s, target=t, kind=k))
return delta
@classmethod
def load(cls, path) -> TaskGraph:
"""从 _export_task_graph_json 写的格式还原 TaskGraph。"""
from pathlib import Path
from air_runtime.io import safe_json_load
p = Path(path)
data = safe_json_load(p)
graph = cls()
if not data or not isinstance(data, dict):
return graph
graph.dispatch_frozen = data.get("dispatchFrozen", False)
for nid, nd in data.get("nodes", {}).items():
graph.nodes[nid] = TaskNode(
id=nd.get("id", nid),
status=nd.get("status", "TODO"),
task=nd.get("task", ""),
files_dirs=nd.get("filesDirs", ""),
done_when=nd.get("doneWhen", ""),
in_degree=nd.get("inDegree", 0),
out_edges=list(nd.get("outEdges", [])),
write_set=list(nd.get("writeSet", [])),
test_required=nd.get("testRequired", False),
adr_refs=list(nd.get("adrRefs", [])),
keep_constraints=list(nd.get("keepConstraints", [])),
)
for ed in data.get("edges", []):
graph.edges.append(Edge(
source=ed["source"], target=ed["target"],
kind=ed.get("kind", "dependency"),
))
return graph
def task_ids_by_status(self, status: str) -> list[str]:
return [nid for nid, n in self.nodes.items() if n.status == status]
def find_cycles(self) -> list[list[str]]:
"""检测依赖环DFS"""
visited: set[str] = set()
rec_stack: set[str] = set()
cycles: list[list[str]] = []
def dfs(node_id: str, path: list[str]) -> None:
visited.add(node_id)
rec_stack.add(node_id)
path.append(node_id)
for target in self.nodes.get(node_id, TaskNode(id=node_id)).out_edges:
if target not in visited:
dfs(target, path.copy())
elif target in rec_stack:
cycle_start = path.index(target)
cycles.append(path[cycle_start:])
rec_stack.discard(node_id)
for nid in self.nodes:
if nid not in visited:
dfs(nid, [])
return cycles
def export_todo_md(self) -> str:
"""导出为人可读的 todo.md 表格,保留 V1 的可见性优势。"""
lines = ["| Task | Status | Files/Dirs | Done When | Validation | ADR |",
"|------|--------|------------|-----------|------------|-----|"]
for nid, node in self.nodes.items():
lines.append(f"| {node.task} | {node.status} | {node.files_dirs} | "
f"{node.done_when} | | |")
return "\n".join(lines) + "\n"
def _remove_node(self, task_id: str) -> None:
if task_id in self.nodes:
del self.nodes[task_id]
self.edges = [e for e in self.edges if e.source != task_id and e.target != task_id]
def _add_node(self, node: TaskNode) -> None:
self.nodes[node.id] = node
def _update_node(self, node: TaskNode) -> None:
if node.id in self.nodes:
existing_status = self.nodes[node.id].status
self.nodes[node.id] = node
# INVALIDATED 可覆盖 DONE/DISPATCHEDP1-21: ADR 级联失效)
if existing_status in ("DISPATCHED", "DONE") and node.status != "INVALIDATED":
self.nodes[node.id].status = existing_status
def _remove_edge(self, edge: Edge) -> None:
self.edges = [e for e in self.edges
if not (e.source == edge.source and e.target == edge.target)]
if edge.target in self.nodes:
self.nodes[edge.target].in_degree = max(0, self.nodes[edge.target].in_degree - 1)
def _add_edge(self, edge: Edge) -> None:
self.edges.append(edge)
if edge.target in self.nodes:
self.nodes[edge.target].in_degree += 1
if edge.source in self.nodes:
self.nodes[edge.source].out_edges.append(edge.target)
# P1-21: ADR 级联失效
def tasks_by_adr(self, adr_id: str) -> list[TaskNode]:
"""查找所有引用指定 ADR 的任务(含已完成)。"""
return [n for n in self.nodes.values() if adr_id in n.adr_refs]
def _find_downstream(self, task_ids: list[str]) -> list[str]:
"""BFS 遍历下游依赖任务。"""
visited: set[str] = set()
queue = list(task_ids)
while queue:
current = queue.pop(0)
if current in visited:
continue
visited.add(current)
node = self.nodes.get(current)
if node:
for target in node.out_edges:
if target not in visited:
queue.append(target)
# 排除起点自身
return [tid for tid in visited if tid not in set(task_ids)]
def invalidate_by_adr(self, adr_id: str, delta: PlanDelta,
impact_labels: dict[str, str] | None = None) -> CascadeReport:
"""P1-21: ADR 变更时级联失效相关任务。
当 impact_labels 为 None 时使用旧行为(所有引用+下游统一 INVALIDATED
当 impact_labels 提供时使用差异化行为IMPACTED→INVALIDATED, BOUNDARY+DONE→验证, SAFE→保留
"""
affected = self.tasks_by_adr(adr_id)
# 1. 冻结调度
self.dispatch_frozen = True
if impact_labels is None:
return self._invalidate_uniform(adr_id, delta, affected)
return self._invalidate_differentiated(adr_id, delta, affected, impact_labels)
def _invalidate_uniform(self, adr_id: str, delta: PlanDelta,
affected: list[TaskNode]) -> CascadeReport:
"""旧行为:所有引用 ADR 的任务及其下游统一 INVALIDATED。"""
completed = [t for t in affected if t.status == "DONE"]
in_progress = [t for t in affected if t.status == "DISPATCHED"]
pending = [t for t in affected if t.status == "TODO"]
for t in completed:
t.status = "INVALIDATED"
delta.removed_tasks.append(t.id)
for t in in_progress:
t.status = "INVALIDATED"
delta.removed_tasks.append(t.id)
for t in pending:
t.status = "INVALIDATED"
delta.removed_tasks.append(t.id)
downstream_ids = self._find_downstream([t.id for t in completed + in_progress + pending])
cascaded = []
for tid in downstream_ids:
node = self.nodes.get(tid)
if node and node.status in ("TODO", "DISPATCHED"):
node.status = "INVALIDATED"
delta.removed_tasks.append(tid)
cascaded.append(tid)
all_invalidated = [t.id for t in completed + in_progress + pending] + cascaded
return CascadeReport(
invalidated_completed=len(completed),
terminated_in_progress=len(in_progress),
cascaded_downstream=len(cascaded),
rollback_ref=delta.rollback_ref,
invalidated_task_ids=all_invalidated,
)
def _invalidate_differentiated(self, adr_id: str, delta: PlanDelta,
affected: list[TaskNode],
impact_labels: dict[str, str]) -> CascadeReport:
"""差异化行为:按 IMPACTED/BOUNDARY/SAFE 分别处理每个任务。"""
invalidated_completed = 0
terminated_in_progress = 0
invalidated_ids: list[str] = []
boundary_ids: list[str] = []
from air_runtime.impact_propagator import ImpactLabel
for t in affected:
label = impact_labels.get(t.id, ImpactLabel.SAFE.value)
if label == ImpactLabel.SAFE.value:
continue
if label == ImpactLabel.BOUNDARY.value:
if t.status == "DONE":
t.meta["verification_needed"] = True
boundary_ids.append(t.id)
elif t.status in ("TODO", "BLOCKED"):
t.status = "INVALIDATED"
delta.removed_tasks.append(t.id)
invalidated_ids.append(t.id)
# DISPATCHED + BOUNDARY: wait for completion, no action
elif label == ImpactLabel.IMPACTED.value:
if t.status == "DONE":
t.status = "INVALIDATED"
delta.removed_tasks.append(t.id)
invalidated_ids.append(t.id)
invalidated_completed += 1
elif t.status == "DISPATCHED":
t.status = "INVALIDATED"
delta.removed_tasks.append(t.id)
invalidated_ids.append(t.id)
terminated_in_progress += 1
elif t.status in ("TODO", "BLOCKED"):
t.status = "INVALIDATED"
delta.removed_tasks.append(t.id)
invalidated_ids.append(t.id)
# 级联下游:仅 IMPACTED 任务的下游受影响
impacted_affected = [t for t in affected
if impact_labels.get(t.id) == ImpactLabel.IMPACTED.value]
cascaded_ids: list[str] = []
if impacted_affected:
downstream_ids = self._find_downstream([t.id for t in impacted_affected])
for tid in downstream_ids:
node = self.nodes.get(tid)
if not node:
continue
dlabel = impact_labels.get(tid, ImpactLabel.SAFE.value)
if dlabel == ImpactLabel.SAFE.value:
continue
if dlabel == ImpactLabel.BOUNDARY.value:
if node.status == "DONE":
node.meta["verification_needed"] = True
boundary_ids.append(tid)
elif node.status in ("TODO", "BLOCKED"):
node.status = "INVALIDATED"
delta.removed_tasks.append(tid)
cascaded_ids.append(tid)
invalidated_ids.append(tid)
elif dlabel == ImpactLabel.IMPACTED.value:
if node.status in ("TODO", "DISPATCHED"):
node.status = "INVALIDATED"
delta.removed_tasks.append(tid)
cascaded_ids.append(tid)
invalidated_ids.append(tid)
elif node.status == "DONE":
node.status = "INVALIDATED"
delta.removed_tasks.append(tid)
cascaded_ids.append(tid)
invalidated_ids.append(tid)
invalidated_completed += 1
return CascadeReport(
invalidated_completed=invalidated_completed,
terminated_in_progress=terminated_in_progress,
cascaded_downstream=len(cascaded_ids),
rollback_ref=delta.rollback_ref,
invalidated_task_ids=invalidated_ids,
boundary_task_ids=boundary_ids,
)
def unfreeze_dispatch(self) -> None:
"""P1-21: 解冻调度,在 Arc 重新生成受影响任务后调用。"""
self.dispatch_frozen = False