- 新增 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>
413 lines
17 KiB
Python
Executable File
413 lines
17 KiB
Python
Executable File
"""
|
||
动态任务依赖图(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,产出 PlanDelta(add/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/DISPATCHED(P1-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
|