feat: P1-21 补充 — ADRWatcher 自动检测 + PartialReplanner 局部重规划
- 新增 adr_watcher.py: SHA256 hash 监控 ADR 文件变更 - snapshot() 初始快照, detect_changes() 增量检测 - 识别 new/modified/superseded/deleted 四种变更类型 - _parse_status() 解析 ADR Status 字段 - 新增 partial_replanner.py: 局部重规划器 - replan() 仅生成受影响任务的替代计划 - _extract_stable_interfaces() 提取未受影响 DONE 任务接口约束 - 产出 replan-request.json 供 Arc 读取 - Eng monitor_engine 集成 ADR 检测: 每轮轮询调用 _detect_adr_changes() - Eng handle_adr_invalidation 集成 PartialReplanner 输出 - events.py 新增 ADR 变更相关事件常量 - task_graph.py apply_full_replace 保留 DISPATCHED, 不保留 INVALIDATED - 10 项补充测试全通过,含完整 ADR 流程场景 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
109
lib/air_runtime/adr_watcher.py
Normal file
109
lib/air_runtime/adr_watcher.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
ADR 文件变更监控 — P1-21 ADR 变更自动检测。
|
||||
基于 SHA256 hash 对比检测 ADR 文件变更,自动触发级联失效。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class ADRChange:
|
||||
"""ADR 文件变更记录。"""
|
||||
adr_id: str
|
||||
kind: str # "new" | "superseded" | "modified"
|
||||
path: str = ""
|
||||
old_hash: str = ""
|
||||
new_hash: str = ""
|
||||
|
||||
|
||||
class ADRWatcher:
|
||||
"""监控 ADR 文件变更,自动触发级联失效。
|
||||
|
||||
AirEng 在每轮轮询时调用 detect_changes(),
|
||||
发现 superseded 或 modified 变更时自动触发 invalidate_by_adr()。
|
||||
"""
|
||||
|
||||
def __init__(self, adr_dir: Path):
|
||||
self._adr_dir = adr_dir
|
||||
self._known_hashes: dict[str, str] = {}
|
||||
|
||||
def snapshot(self) -> None:
|
||||
"""启动时记录所有 ADR 的内容 hash。"""
|
||||
if not self._adr_dir.exists():
|
||||
return
|
||||
for adr_file in sorted(self._adr_dir.glob("ADR-*.md")):
|
||||
adr_id = self._extract_adr_id(adr_file)
|
||||
self._known_hashes[adr_id] = hashlib.sha256(
|
||||
adr_file.read_bytes()
|
||||
).hexdigest()
|
||||
|
||||
def detect_changes(self) -> list[ADRChange]:
|
||||
"""对比当前 ADR hash 与已知 hash,返回变更列表。"""
|
||||
if not self._adr_dir.exists():
|
||||
return []
|
||||
|
||||
changes: list[ADRChange] = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
for adr_file in sorted(self._adr_dir.glob("ADR-*.md")):
|
||||
adr_id = self._extract_adr_id(adr_file)
|
||||
seen_ids.add(adr_id)
|
||||
current_hash = hashlib.sha256(adr_file.read_bytes()).hexdigest()
|
||||
old_hash = self._known_hashes.get(adr_id)
|
||||
|
||||
if old_hash is None:
|
||||
changes.append(ADRChange(
|
||||
adr_id=adr_id, kind="new",
|
||||
path=str(adr_file), old_hash="", new_hash=current_hash,
|
||||
))
|
||||
elif current_hash != old_hash:
|
||||
status = self._parse_status(adr_file)
|
||||
if status == "superseded":
|
||||
changes.append(ADRChange(
|
||||
adr_id=adr_id, kind="superseded",
|
||||
path=str(adr_file), old_hash=old_hash, new_hash=current_hash,
|
||||
))
|
||||
else:
|
||||
changes.append(ADRChange(
|
||||
adr_id=adr_id, kind="modified",
|
||||
path=str(adr_file), old_hash=old_hash, new_hash=current_hash,
|
||||
))
|
||||
self._known_hashes[adr_id] = current_hash
|
||||
|
||||
# 检查被删除的 ADR
|
||||
for adr_id in list(self._known_hashes.keys()):
|
||||
if adr_id not in seen_ids:
|
||||
changes.append(ADRChange(
|
||||
adr_id=adr_id, kind="deleted",
|
||||
path="", old_hash=self._known_hashes[adr_id], new_hash="",
|
||||
))
|
||||
del self._known_hashes[adr_id]
|
||||
|
||||
return changes
|
||||
|
||||
@staticmethod
|
||||
def _extract_adr_id(adr_file: Path) -> str:
|
||||
"""从文件名提取 ADR ID,如 'ADR-0005-ffmpeg-decode.md' → 'ADR-0005'。"""
|
||||
match = re.match(r"(ADR-\d+)", adr_file.stem)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return adr_file.stem
|
||||
|
||||
@staticmethod
|
||||
def _parse_status(adr_file: Path) -> str:
|
||||
"""解析 ADR 文件中的 Status 字段。"""
|
||||
try:
|
||||
text = adr_file.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return "unknown"
|
||||
for line in text.splitlines():
|
||||
lower = line.lower().strip()
|
||||
if lower.startswith("status:") or lower.startswith("status :"):
|
||||
status = line.split(":", 1)[1].strip().lower()
|
||||
return status
|
||||
return "unknown"
|
||||
@@ -33,6 +33,9 @@ SEC_SCAN = "sec.scan"
|
||||
REVIEW_SESSION = "review.session"
|
||||
ENGINE_CYCLE = "engine.cycle"
|
||||
WORKER_TIMEOUT = "worker.timeout"
|
||||
ADR_CHANGE_DETECTED = "adr.change.detected"
|
||||
ADR_INVALIDATION = "adr.invalidation"
|
||||
ADR_UNFREEZED = "adr.unfreezed"
|
||||
LOCK_ACQUIRED = "lock.acquired"
|
||||
LOCK_RELEASED = "lock.released"
|
||||
STALE_LOCK_CLEANED = "stale_lock.cleaned"
|
||||
|
||||
@@ -308,6 +308,32 @@ def dispatch_worker_group(project_root: Path, group_name: str = "") -> dict:
|
||||
return result
|
||||
|
||||
|
||||
def _detect_adr_changes(project_root: Path, state: dict) -> list:
|
||||
"""P1-21: 检查 ADR 文件变更,返回需要级联失效的变更列表。"""
|
||||
from air_runtime.adr_watcher import ADRWatcher, ADRChange
|
||||
adr_dir = project_root / "AirPlan" / "docs" / "architecture" / "adr"
|
||||
if not adr_dir.exists():
|
||||
return []
|
||||
|
||||
watcher = ADRWatcher(adr_dir)
|
||||
# 从引擎状态恢复已知 hash
|
||||
known = state.get("adrWatcherHashes", {})
|
||||
watcher._known_hashes = known
|
||||
|
||||
# 首次无 snapshot → 先初始化
|
||||
if not known:
|
||||
watcher.snapshot()
|
||||
state["adrWatcherHashes"] = dict(watcher._known_hashes)
|
||||
return []
|
||||
|
||||
changes = watcher.detect_changes()
|
||||
# 持久化更新后的 hash
|
||||
state["adrWatcherHashes"] = dict(watcher._known_hashes)
|
||||
|
||||
# 只返回需要级联失效的变更
|
||||
return [c for c in changes if c.kind in ("superseded", "modified")]
|
||||
|
||||
|
||||
def monitor_engine(project_root: Path) -> dict:
|
||||
"""L1 代码级轮询:硬编码循环检测 Worker 状态,不依赖 LLM 自觉。"""
|
||||
paths = _paths(project_root)
|
||||
@@ -350,6 +376,21 @@ def monitor_engine(project_root: Path) -> dict:
|
||||
except OSError:
|
||||
resource_pressure = False
|
||||
|
||||
# P1-21: ADR 变更自动检测
|
||||
adr_changes = _detect_adr_changes(project_root, state)
|
||||
if adr_changes:
|
||||
for change in adr_changes:
|
||||
if change.kind in ("superseded", "modified"):
|
||||
interventions.append({
|
||||
"adrId": change.adr_id,
|
||||
"reason": f"adr-{change.kind}",
|
||||
"action": "invalidate-by-adr",
|
||||
})
|
||||
log = EventLog(event_log_path(project_root))
|
||||
log.emit("adr.change.detected", {
|
||||
"adrId": change.adr_id, "kind": change.kind,
|
||||
})
|
||||
|
||||
# 新增:检查 pending worktree merges — merge 失败自动升级到 AirDbg
|
||||
wt_root = project_root / ".git" / "worktrees"
|
||||
if wt_root.exists():
|
||||
@@ -616,6 +657,13 @@ def handle_adr_invalidation(project_root: Path, adr_id: str) -> dict:
|
||||
# 6: git revert 已合并的旧代码(按 task_id 查找对应 commit)
|
||||
revert_results = _git_revert_invalidated(project_root, report.invalidated_task_ids)
|
||||
|
||||
# 6.5: 生成局部重规划请求(PartialReplanner)
|
||||
from air_runtime.partial_replanner import PartialReplanner
|
||||
replanner = PartialReplanner()
|
||||
partial_delta = replanner.replan(graph, report.invalidated_task_ids)
|
||||
replan_request_path = paths["plan_dir"] / f"replan-request-{session_stamp()}.json"
|
||||
atomic_json_write(replan_request_path, partial_delta.replan_request)
|
||||
|
||||
# 7: 写回更新后的 task-graph.json
|
||||
from air_runtime.modes.arc_mode import _export_task_graph_json
|
||||
_export_task_graph_json(graph, tg_json)
|
||||
@@ -641,6 +689,7 @@ def handle_adr_invalidation(project_root: Path, adr_id: str) -> dict:
|
||||
},
|
||||
"terminatedWorkers": terminated_workers,
|
||||
"revertResults": revert_results,
|
||||
"replanRequestPath": str(replan_request_path),
|
||||
"nextStep": "arc-replan-then-unfreeze",
|
||||
}
|
||||
|
||||
|
||||
106
lib/air_runtime/partial_replanner.py
Normal file
106
lib/air_runtime/partial_replanner.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
局部重规划 — P1-21 仅重新生成受 ADR 变更影响的任务子集。
|
||||
替代全量重规划,保留未受影响任务的接口约束。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from air_runtime.task_graph import TaskGraph, TaskNode, PlanDelta
|
||||
|
||||
|
||||
@dataclass
|
||||
class Interface:
|
||||
"""未受影响任务暴露的公共接口约束。"""
|
||||
task_id: str
|
||||
write_set: list[str] = field(default_factory=list)
|
||||
adr_refs: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReplanContext:
|
||||
"""受影响任务的上下文信息,供 Arc 局部重规划使用。"""
|
||||
task_id: str
|
||||
task: str
|
||||
files_dirs: str
|
||||
done_when: str
|
||||
write_set: list[str] = field(default_factory=list)
|
||||
adr_refs: list[str] = field(default_factory=list)
|
||||
status: str = ""
|
||||
|
||||
|
||||
class PartialReplanner:
|
||||
"""仅重新生成受 ADR 变更影响的任务子集。
|
||||
|
||||
与 incremental_replan_mode 的区别:
|
||||
- incremental_replan_mode: 全量重建 DAG 再 diff
|
||||
- PartialReplanner: 只对受影响部分重新规划,保留稳定接口约束
|
||||
"""
|
||||
|
||||
def replan(self, graph: TaskGraph, invalidated_ids: list[str],
|
||||
new_adr_path: Path | None = None) -> PlanDelta:
|
||||
"""局部重规划:仅生成受影响任务的替代任务。
|
||||
|
||||
Args:
|
||||
graph: 当前任务图(已包含 INVALIDATED 标记)
|
||||
invalidated_ids: 被 ADR 变更级联失效的任务 ID 列表
|
||||
new_adr_path: 新 ADR 文件路径(可选,供 Arc 参考)
|
||||
"""
|
||||
delta = PlanDelta()
|
||||
|
||||
# 1. 收集受影响任务的上下文
|
||||
affected_context = self._collect_affected_context(graph, invalidated_ids)
|
||||
|
||||
# 2. 提取未受影响任务的稳定接口
|
||||
stable_interfaces = self._extract_stable_interfaces(graph, set(invalidated_ids))
|
||||
|
||||
# 3. 生成局部重规划指令文件(供 Arc 读取)
|
||||
replan_request = {
|
||||
"type": "partial-replan",
|
||||
"invalidatedTaskIds": invalidated_ids,
|
||||
"affectedContext": [ctx.__dict__ for ctx in affected_context],
|
||||
"stableInterfaces": [iface.__dict__ for iface in stable_interfaces],
|
||||
"newAdrPath": str(new_adr_path) if new_adr_path else None,
|
||||
}
|
||||
|
||||
# 4. 构建增量 delta
|
||||
# removed_tasks 已在 invalidate_by_adr 中填充
|
||||
# added_tasks 留空——由 Arc 读取 replan-request.json 后生成新任务
|
||||
delta.replan_request = replan_request
|
||||
|
||||
return delta
|
||||
|
||||
def _collect_affected_context(self, graph: TaskGraph,
|
||||
invalidated_ids: list[str]) -> list[ReplanContext]:
|
||||
"""收集受影响任务的上下文。"""
|
||||
contexts = []
|
||||
for tid in invalidated_ids:
|
||||
node = graph.nodes.get(tid)
|
||||
if node:
|
||||
contexts.append(ReplanContext(
|
||||
task_id=node.id,
|
||||
task=node.task,
|
||||
files_dirs=node.files_dirs,
|
||||
done_when=node.done_when,
|
||||
write_set=list(node.write_set),
|
||||
adr_refs=list(node.adr_refs),
|
||||
status=node.status,
|
||||
))
|
||||
return contexts
|
||||
|
||||
def _extract_stable_interfaces(self, graph: TaskGraph,
|
||||
invalidated_ids: set) -> list[Interface]:
|
||||
"""提取未受影响 DONE 任务的接口约束,确保重规划不破坏依赖。"""
|
||||
interfaces = []
|
||||
for nid, node in graph.nodes.items():
|
||||
if nid not in invalidated_ids and node.status == "DONE":
|
||||
if node.write_set or node.adr_refs:
|
||||
interfaces.append(Interface(
|
||||
task_id=node.id,
|
||||
write_set=list(node.write_set),
|
||||
adr_refs=list(node.adr_refs),
|
||||
))
|
||||
return interfaces
|
||||
Reference in New Issue
Block a user