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

@@ -4,6 +4,7 @@
================================================================================
【已完成】
- V10.502 修正 AiderHeal 自動修復診斷鏈:先做 ADR-020 檔案白名單再打 110 preflight`tests/` finding 會明確略過而不誤報 repo preflightCode Review 完成通知會把全數不在白名單的 finding 標成需人工處理,不再宣稱已觸發 AiderHeal白名單放行 `services/routes/database` 子目錄 Python 檔preflight 通知帶 stderr/stdout 細節,健康檢查同時接受 `/health` 回 `ok` 與 `healthy`。
- V10.501 新增市場情報 MCP Fetch Candidate Queue Writer Post-Closeout Inventory Review 安全預覽 gate只審核 closeout review 後由操作員 shell 完成的 live inventory read-only 摘要,確認 closeout linkage、row count、inventory artifact、closeout review artifact、read-only query result、missing/duplicate rows 與 operator confirmationAPI 不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 inventory query、不掛 scheduler。
- V10.500 新增市場情報 MCP Fetch Candidate Queue Writer Run Closeout Review 安全預覽 gate只審核 receipt review 通過後的 operator closeout 摘要,確認 receipt linkage、closeout artifact、receipt review artifact、post-closeout inventory plan、writer output / post-write smoke / backup manifest、rollback note 與 operator confirmationAPI 不讀 receipt 原文、不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 post-closeout query、不掛 scheduler。
- V10.499 新增市場情報 MCP Fetch Candidate Queue Writer Run Receipt Review 安全預覽 gate只審核操作員 shell writer run 後貼回的 receipt 摘要,確認 readiness linkage、run package id、候選/dedupe keys、writer output、post-write smoke、backup path 與 operator confirmationAPI 不讀 receipt 原文、不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 post-write query、不掛 scheduler。

View File

@@ -350,7 +350,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.501"
SYSTEM_VERSION = "V10.502"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -97,6 +97,7 @@
- 2026-05-31 起,`V10.499` 新增市場情報 MCP Fetch Candidate Queue Writer Run Receipt Review gate在 run readiness 通過後只審核操作員 shell writer run 的 receipt 摘要,要求 readiness linkage、run package id、候選/dedupe keys、writer output、post-write smoke、backup path 與 operator confirmation 對齊;仍不讀 receipt 原文、不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 post-write query、不掛 scheduler只放行到 closeout review。
- 2026-05-31 起,`V10.500` 新增市場情報 MCP Fetch Candidate Queue Writer Run Closeout Review gate在 receipt review 通過後只審核 operator closeout 摘要,要求 receipt linkage、closeout artifact、receipt review artifact、post-closeout inventory plan、writer output / post-write smoke / backup manifest、rollback note 與 operator confirmation 對齊;仍不讀 receipt 原文、不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 post-closeout query、不掛 scheduler只放行到 read-only post-closeout inventory review。
- 2026-05-31 起,`V10.501` 新增市場情報 MCP Fetch Candidate Queue Writer Post-Closeout Inventory Review gate在 closeout review 通過後只審核 operator live inventory read-only 摘要,要求 closeout linkage、row count、inventory artifact、closeout review artifact、read-only query result、missing/duplicate rows 與 operator confirmation 對齊;仍不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 inventory query、不掛 scheduler只放行到 candidate queue review handoff。
- 2026-05-31 起,`V10.502` 修正 AiderHeal 自動修復診斷鏈:先檢查 ADR-020 檔案白名單再執行 110 preflight`tests/` finding 會明確略過而不誤報 repo preflightCode Review 完成通知會把全數不在白名單的 finding 標成需人工處理,不再宣稱已觸發 AiderHeal白名單放行 `services/routes/database` 子目錄 Python 檔preflight 通知帶遠端錯誤細節,健康檢查接受 `/health``healthy`
## 3. 12 Agent 決策信封整合

View File

@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-05-24PChome 近門檻身份回收第二輪
- **V10.502 AiderHeal 自動修復診斷鏈修正**: `execute_code_fix()` 改為先檢查 ADR-020 檔案白名單再執行 110 preflight避免 `tests/...` 這類不得自動修的 finding 被誤報成 `/home/wooo/ewoooc` preflight 失敗Code Review 完成通知會把全數不在白名單的 finding 標成需人工處理,不再宣稱已觸發 AiderHeal白名單同步放行 `services/routes/database` 子目錄 Python 檔preflight 通知帶 stderr/stdout 細節,健康檢查接受正式 `/health` 回傳的 `healthy`
- **V10.501 市場情報 MCP Fetch Candidate Queue Writer Post-Closeout Inventory Review gate**: 新增 `/api/market_intel/mcp_fetch_candidate_queue_writer_post_closeout_inventory_review` 與 UI preview只審核 closeout review 通過後的 operator live inventory read-only 摘要;要求 closeout linkage、row count、inventory artifact、closeout review artifact、read-only query result、missing/duplicate rows 與 operator confirmation 對齊,且 API 不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 inventory query、不掛 scheduler只放行到 candidate queue review handoff。
- **V10.500 市場情報 MCP Fetch Candidate Queue Writer Run Closeout Review gate**: 新增 `/api/market_intel/mcp_fetch_candidate_queue_writer_run_closeout_review` 與 UI preview只審核 receipt review 通過後的 operator closeout 摘要;要求 receipt linkage、closeout artifact、receipt review artifact、post-closeout inventory plan、writer output / post-write smoke / backup manifest、rollback note 與 operator confirmation 對齊,且 API 不讀 receipt 原文、不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 post-closeout query、不掛 scheduler只放行到 read-only post-closeout inventory review。
- **V10.499 市場情報 MCP Fetch Candidate Queue Writer Run Receipt Review gate**: 新增 `/api/market_intel/mcp_fetch_candidate_queue_writer_run_receipt_review` 與 UI preview只審核操作員 shell writer run 後貼回的 receipt 摘要;要求 readiness linkage、run package id、候選/dedupe keys、writer output、post-write smoke、backup path 與 operator confirmation 對齊,且 API 不讀 receipt 原文、不讀 token、不執行 CLI、不開 DB、不寫 queue、不做 post-write query、不掛 scheduler只放行到 closeout review。

View File

@@ -182,8 +182,8 @@ ssh ollama@192.168.0.188 'docker logs momo-pro-system --since 10m 2>&1 | grep -E
| L | 機制 | 觸發點 |
|---|------|-------|
| L0 | preflight 路徑檢查 | `aider_heal_executor.py:execute_code_fix` 開頭 |
| L1 | 檔案白名單 `^(services\|routes\|database)/[a-zA-Z0-9_]+\.py$` | `ALLOWED_FILE_PATTERN` |
| L0 | preflight 路徑檢查 | `aider_heal_executor.py:execute_code_fix` 白名單通過後 |
| L1 | 檔案白名單 `^(services\|routes\|database)/(?:[a-zA-Z0-9_]+/)*[a-zA-Z0-9_]+\.py$`,允許子目錄但不允許 `tests/` | `ALLOWED_FILE_PATTERN` |
| L2 | diff > 50 行拒絕 push | `AIDER_MAX_DIFF_LINES` |
| L3 | 每小時最多 5 次 CODE_FIX | `_enforce_rate_limit` |
| L4 | health check 失敗自動 git revert | `_revert_last_commit` |

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:

View File

@@ -0,0 +1,45 @@
def test_aider_heal_allowed_file_pattern_accepts_nested_service_modules():
from services import aider_heal_executor as svc
assert svc.ALLOWED_FILE_PATTERN.match("services/market_intel/phase.py")
assert svc.ALLOWED_FILE_PATTERN.match("routes/market_intel_mcp_run_routes.py")
assert svc.ALLOWED_FILE_PATTERN.match("database/ai_models.py")
assert not svc.ALLOWED_FILE_PATTERN.match("tests/test_market_intel_skeleton.py")
assert not svc.ALLOWED_FILE_PATTERN.match("config.py")
def test_aider_heal_rejects_disallowed_file_before_ssh(monkeypatch):
from services import aider_heal_executor as svc
messages = []
def fail_if_called(*_args, **_kwargs):
raise AssertionError("SSH preflight should not run for disallowed files")
monkeypatch.setattr(svc, "_ssh_exec", fail_if_called)
monkeypatch.setattr(svc, "_notify_telegram", messages.append)
result = svc.execute_code_fix(
error_type="code_review_security",
error_message="疑似硬編碼敏感字串",
target_file="tests/test_market_intel_skeleton.py",
)
assert result["success"] is False
assert result["commit_sha"] is None
assert result["reverted"] is False
assert "不在 ADR-020 自動修復白名單" in result["message"]
assert messages
assert "已略過自動修復" in messages[0]
def test_aider_heal_health_accepts_current_healthy_status(monkeypatch):
from services import aider_heal_executor as svc
monkeypatch.setattr(svc, "_http_get_json", lambda _url: {"status": "healthy"})
assert svc._wait_for_health(
"https://mo.wooo.work/health",
timeout_seconds=1,
interval_seconds=0,
) is True

View File

@@ -130,3 +130,43 @@ def test_guard_upgrades_llm_human_review_true_to_false(monkeypatch):
assert guarded["auto_fix"] is True
assert guarded["human_review_needed"] is False
def test_complete_notification_marks_non_whitelisted_aider_files(monkeypatch):
"""tests/docs/config 等 finding 不應在完成通知中宣稱 AiderHeal 會自動修復。"""
import services.telegram_templates as telegram_templates
import services.code_review_pipeline_service as module
messages = []
monkeypatch.setattr(telegram_templates, "_send_telegram_raw", messages.append)
pipeline = module.CodeReviewPipeline(
"abcdef123456",
["tests/test_market_intel_skeleton.py"],
)
pipeline.state["severity_summary"] = {
"critical": 0,
"high": 1,
"medium": 0,
"low": 0,
}
pipeline._notify_complete(
[
{
"severity": "HIGH",
"description": "疑似硬編碼敏感字串",
"file": "tests/test_market_intel_skeleton.py",
}
],
"",
{
"priority": "high",
"auto_fix": True,
"fix_files": ["tests/test_market_intel_skeleton.py"],
},
)
assert messages
assert "不在自動修復白名單" in messages[0]
assert "已觸發自動修復" not in messages[0]