feat: AirPlan V2 — 全专家插件强制路由 + 事件系统规范化
P0-8 扩大: do_mode.py finish_worker 全专家插件强制路由 - GUI→XDB, network→NDB, C/C++→SDB, done→Rvr, blocked/failed→Dbg - 证据去重: 已有 xdbSessions/ndbSessions/sdbReports/rvrReviewed 则跳过 P1-GAP17: 事件 emit 规范化 - 新增 7 个事件常量 (TASK_ENTERED, TASK_FINISHED, ENGINE_ENTERED 等) - 全部 emit 调用替换字符串字面量为常量,零残留 - 30 个事件类型常量全部定义且唯一 P1-GAP18: 事件日志原子轮转 - emit 计数器每 128 次检查轮转,避免每次 emit 读文件 - 清除未使用的 _emit_with_completion/_pending_merge_complete - 原子轮转: tempfile+os.replace 保证不损坏 eng 极端接管: 强制调用全部专家插件 (Dbg/XDB/NDB/SDB/Rvr) commands/do.md: 更新为全专家插件路由文档 全量测试: 69 通过, 0 失败 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"
|
||||
Reference in New Issue
Block a user