V10.502 修正 AiderHeal 自動修復診斷

This commit is contained in:
OoO
2026-05-31 16:47:08 +08:00
parent 46aa89ddfa
commit b7838b382f
9 changed files with 168 additions and 29 deletions

View File

@@ -22,10 +22,10 @@ import re
import time
import threading
import shlex
import html
import requests
from datetime import datetime, timedelta
from datetime import datetime
from typing import Optional, Dict, Any, List
from pathlib import Path
from services.logger_manager import SystemLogger
from utils.ssh_helper import run_ssh_command
@@ -79,8 +79,10 @@ except Exception:
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
# 允許 Aider 修改的路徑(正規表示式)
# ADR-020 白名單允許 services/routes/database 底下的 Python 模組,含子目錄;
# tests/docs/config 等檔案仍需人工處理,避免 Aider 以「修測試」掩蓋產品問題。
ALLOWED_FILE_PATTERN = re.compile(
r"^(services|routes|database)/[a-zA-Z0-9_]+\.py$"
r"^(services|routes|database)/(?:[a-zA-Z0-9_]+/)*[a-zA-Z0-9_]+\.py$"
)
# ── 速率控制(執行緒安全) ────────────────────────────────────────────────────
@@ -159,7 +161,7 @@ def _wait_for_health(
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
data = _http_get_json(url)
if data and data.get("status") == "ok":
if data and str(data.get("status", "")).lower() in {"ok", "healthy"}:
return True
time.sleep(interval_seconds)
return False
@@ -225,24 +227,57 @@ def execute_code_fix(
"""
ts = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
ctx: Dict[str, Any] = context or {}
repo = Path(REPO_PATH_110).expanduser()
# L1檔案白名單。必須先於 110 preflight 執行,否則 tests/docs 等
# 本來就不能自動修的 finding 會被誤報成「110 repo 不存在」。
if not ALLOWED_FILE_PATTERN.match(target_file):
reason = f"[AiderHeal] 檔案不在 ADR-020 自動修復白名單:{target_file}"
logger.warning("event=heal_reject reason=path_not_allowed file=%s", target_file)
_notify_telegram(
f"⚠️ <b>AiderHeal 已略過自動修復</b>\n"
f"├ 檔案:<code>{html.escape(target_file[:200])}</code>\n"
f"├ 原因:不在 ADR-020 自動修復白名單(僅允許 services/routes/database 內 Python 檔案)\n"
f"└ 動作:請人工確認 finding或調整白名單後重跑 Code Review"
)
return {
"success": False,
"action": "CODE_FIX",
"message": reason,
"commit_sha": None,
"reverted": False,
}
# L0preflight — 確認 110 上的 repo 路徑真的存在且是 git repo
# 沒有這個檢查時,後續 cd $REPO_PATH 失敗會被 shell `|| true` 吞掉,
# 導致整條 pipeline 走完卻 0 次 push靜默 100% no-op2026-05-03 實測)
rc_pre, _, _ = _ssh_exec(
f"test -d {shlex.quote(REPO_PATH_110)}/.git", timeout=10
preflight_cmd = (
f"test -d {shlex.quote(REPO_PATH_110)} && "
f"test -d {shlex.quote(REPO_PATH_110)}/.git && "
f"cd {shlex.quote(REPO_PATH_110)} && "
f"git rev-parse --is-inside-work-tree 2>&1"
)
rc_pre, out_pre, err_pre = _ssh_exec(preflight_cmd, timeout=10)
if rc_pre != 0:
preflight_detail = (err_pre or out_pre or "").strip()
if not preflight_detail:
preflight_detail = "SSH 逾時、repo 路徑不存在,或目標不是 git repo"
msg = (
f"[AiderHeal] preflight 失敗110 主機上 {REPO_PATH_110} 不存在或不是 git repo。"
f"請檢查 AIDER_REPO_PATH env / 在 110 上 git clone repo見 ADR-020 SOP"
f"請檢查 AIDER_REPO_PATH env / 在 110 上 git clone repo見 ADR-020 SOP"
f"detail={preflight_detail[:300]}"
)
logger.error(
"event=preflight_failed path=%s rc=%s stderr=%s stdout=%s",
REPO_PATH_110,
rc_pre,
err_pre,
out_pre,
)
logger.error("event=preflight_failed path=%s", REPO_PATH_110)
_notify_telegram(
f"🚨 <b>AiderHeal preflight 失敗</b>\n"
f"├ 路徑:<code>{REPO_PATH_110}</code>\n"
f"├ 主機:<code>{HEAL_SSH_HOST}</code>\n"
f"├ 細節:<code>{html.escape(preflight_detail[:240])}</code>\n"
f"└ 動作:請依 ADR-020 SOP 在 110 上 clone repo 並設好 push 權限"
)
return {
@@ -253,18 +288,6 @@ def execute_code_fix(
"reverted": False,
}
# L1檔案白名單
if not ALLOWED_FILE_PATTERN.match(target_file):
reason = f"[AiderHeal] 檔案不在白名單:{target_file}"
logger.warning("event=heal_reject reason=%s file=%s", reason, target_file)
return {
"success": False,
"action": "CODE_FIX",
"message": reason,
"commit_sha": None,
"reverted": False,
}
# L3速率限制
if not _enforce_rate_limit():
reason = f"[AiderHeal] 每小時上限 {MAX_HOURLY_FIX} 次,跳過"

View File

@@ -97,12 +97,20 @@ CODE_REVIEW_HERMES_LLM_SCAN_ENABLED = (
INTERNAL_TOKEN = os.getenv("INTERNAL_WEBHOOK_TOKEN", "")
AUTO_FIX_ENABLED = os.getenv("CODE_REVIEW_AUTO_FIX_ENABLED", "true").lower() == "true"
ALLOW_INSECURE_WEBHOOK = os.getenv("MOMO_ALLOW_INSECURE_INTERNAL_WEBHOOK_FOR_DEV", "").lower() == "true"
AIDER_AUTO_FIX_FILE_PATTERN = re.compile(
r"^(services|routes|database)/(?:[a-zA-Z0-9_]+/)*[a-zA-Z0-9_]+\.py$"
)
# Phase 7 Frontier 升級 feature flag — 預設 OFF啟用後只作 Ollama 失敗後的雲端備援。
CODE_REVIEW_USE_CLAUDE = os.getenv("CODE_REVIEW_USE_CLAUDE", "false").lower() == "true"
CLAUDE_REVIEW_MODEL = os.getenv("CLAUDE_MODEL", "claude-opus-4-7")
def _aider_allowed_fix_files(files: List[str]) -> List[str]:
"""回傳 ADR-020 允許交給 AiderHeal 自動修復的檔案。"""
return [f for f in files if AIDER_AUTO_FIX_FILE_PATTERN.match(f or "")]
# ═══════════════════════════════════════════════════════════════════════════════
# Pipeline Class
# ═══════════════════════════════════════════════════════════════════════════════
@@ -821,6 +829,7 @@ class CodeReviewPipeline:
def _nemotron_dispatch(self, ea: Dict, findings: List[Dict]) -> Dict:
auto_fix = ea.get("auto_fix", False)
fix_files = ea.get("fix_files", [])
allowed_fix_files = _aider_allowed_fix_files(fix_files)
priority_map = {"critical": 1, "high": 2, "medium": 3, "low": 4}
priority_num = priority_map.get(ea.get("priority", "low"), 4)
@@ -841,13 +850,20 @@ class CodeReviewPipeline:
('code_review_fix', :desc, :status, :priority, :meta, NOW())
"""), {
"desc": desc[:500],
"status": "auto_pending" if auto_fix else "auto_disabled",
"status": (
"auto_pending"
if auto_fix and fpath in allowed_fix_files
else "auto_skipped_whitelist"
if auto_fix
else "auto_disabled"
),
"priority": priority_num,
"meta": json.dumps({
"pipeline_id": self.pipeline_id,
"commit_sha": self.commit_sha,
"file": fpath,
"auto_fix": auto_fix,
"aider_auto_fix_allowed": fpath in allowed_fix_files,
"ea_priority": ea.get("priority"),
"findings": related,
}, ensure_ascii=False),
@@ -861,12 +877,20 @@ class CodeReviewPipeline:
session.close()
# 觸發 AiderHeal非阻塞
if auto_fix and fix_files:
if auto_fix and allowed_fix_files:
self.state["auto_fix_triggered"] = True
self._sync_global()
self._trigger_aider_heal(findings, fix_files)
self._trigger_aider_heal(findings, allowed_fix_files)
elif auto_fix and fix_files:
self.state["auto_fix_skipped_whitelist"] = True
self._sync_global()
logger.info("[CodeReview] AiderHeal skipped: no files matched ADR-020 whitelist files=%s", fix_files)
return {"actions": actions_created, "auto_fix": auto_fix}
return {
"actions": actions_created,
"auto_fix": auto_fix,
"aider_fix_files": allowed_fix_files,
}
def _trigger_aider_heal(self, findings: List[Dict], fix_files: List[str]):
"""非阻塞觸發 AiderHeal 自動修復"""
@@ -965,6 +989,8 @@ class CodeReviewPipeline:
sev = self.state["severity_summary"]
priority = ea.get("priority", "medium")
auto_fix = ea.get("auto_fix", False)
fix_files = ea.get("fix_files", [])
allowed_fix_files = _aider_allowed_fix_files(fix_files)
icon = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "🟢"}.get(priority, "🟡")
top_issues = [f for f in findings if f.get("severity") in ("CRITICAL", "HIGH")][:3]
@@ -986,8 +1012,10 @@ class CodeReviewPipeline:
if openclaw_report:
msg += f"\n{openclaw_report[:400]}\n"
if auto_fix:
if auto_fix and allowed_fix_files:
fix_status = "🔧 已觸發自動修復AiderHeal"
elif auto_fix and fix_files:
fix_status = "⚠️ 不在自動修復白名單,需人工處理"
elif sev['critical'] + sev['high'] + sev['medium'] + sev['low'] == 0:
fix_status = "✅ 無需修復動作"
else: