From 405350c774f79f9e85c260d3885d31ee4ae3208b Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 20 Jul 2026 10:15:15 +0800 Subject: [PATCH] Preserve plugin diagnostics without degrading overall status --- app/agent/tools/impl/query_doctor_report.py | 5 +- app/cli.py | 2 +- app/doctor/checks.py | 114 ++++++++++++++--- app/doctor/formatters.py | 2 + app/doctor/models.py | 8 +- app/doctor/runner.py | 15 +++ docs/cli.md | 1 + docs/doctor.md | 2 + docs/mcp-api.md | 4 + moviepilot | 1 + skills/feedback-issue/SKILL.md | 4 +- skills/moviepilot-cli/SKILL.md | 5 +- tests/test_agent_doctor_tool.py | 2 + tests/test_doctor.py | 128 ++++++++++++++++++++ 14 files changed, 270 insertions(+), 23 deletions(-) diff --git a/app/agent/tools/impl/query_doctor_report.py b/app/agent/tools/impl/query_doctor_report.py index 4e9a4958d..2cca63fe7 100644 --- a/app/agent/tools/impl/query_doctor_report.py +++ b/app/agent/tools/impl/query_doctor_report.py @@ -44,7 +44,9 @@ class QueryDoctorReportTool(MoviePilotTool): description: str = ( "Run MoviePilot Doctor in read-only mode and return a structured diagnostic report for troubleshooting. " "Use this tool when analyzing startup failures, Docker/runtime issues, port conflicts, dependency problems, " - "database health, frontend assets, safe mode, or recent log error clues. This tool never applies fixes." + "database health, frontend assets, safe mode, or recent log error clues. Plugin-only log findings remain " + "visible with affects_report_status=false and do not downgrade the overall status. This tool never applies " + "fixes." ) require_admin: bool = True args_schema: Type[BaseModel] = QueryDoctorReportInput @@ -73,6 +75,7 @@ class QueryDoctorReportTool(MoviePilotTool): "title": item.get("title"), "fixable": item.get("fixable"), "fixed": item.get("fixed"), + "affects_report_status": item.get("affects_report_status", True), } for item in report.get("findings") or [] if isinstance(item, dict) diff --git a/app/cli.py b/app/cli.py index 8cd7c014e..f96d2ec6a 100644 --- a/app/cli.py +++ b/app/cli.py @@ -988,7 +988,7 @@ def logs(lines: int, follow: bool, stdio: bool, frontend_log: bool) -> None: @click.option("--fix", is_flag=True, help="执行白名单安全修复") @click.option("--deep", is_flag=True, help="执行可能较慢的深度检查") def doctor(json_output: bool, fix: bool, deep: bool) -> None: - """离线诊断本地 MoviePilot 运行环境""" + """离线诊断本地 MoviePilot 运行环境,插件日志告警不影响整体状态""" from app.doctor import run_doctor from app.doctor.formatters import format_json_report, format_text_report diff --git a/app/doctor/checks.py b/app/doctor/checks.py index 1c1ea8209..6f0607f93 100644 --- a/app/doctor/checks.py +++ b/app/doctor/checks.py @@ -45,6 +45,14 @@ LOG_ERROR_PATTERNS = ( re.compile(r"加载插件.+出错"), re.compile(r"数据库更新失败"), ) +LOG_RECORD_PATTERN = re.compile( + r"(?:【(?:DEBUG|INFO|WARNING|ERROR|CRITICAL)】|(?:DEBUG|INFO|WARNING|ERROR|CRITICAL):)" +) +CONSOLE_LOGGER_PATTERN = re.compile(r"\[([^\]]+)]") +PLUGIN_ERROR_PATTERNS = ( + re.compile(r"(?:^|\s-\s)plugin\.py\s+-\s", re.IGNORECASE), + re.compile(r"插件.+(?:出错|失败|异常|错误)"), +) SENSITIVE_PATTERNS = ( re.compile(r"(?i)(api[_-]?token|token|password|secret|cookie)(\s*[:=]\s*)[^\s&]+"), re.compile(r"\bghp_[A-Za-z0-9]{20,}\b"), @@ -92,10 +100,23 @@ class DoctorRunnerProtocol: recommendation: str, fixable: bool = False, fixed: bool = False, + affects_report_status: bool = True, context: Optional[dict[str, Any]] = None, ) -> DoctorFinding: """ 添加诊断发现。 + + :param finding_id: 诊断项稳定标识 + :param severity: 诊断严重级别 + :param status: 单项诊断状态 + :param title: 诊断项标题 + :param detail: 诊断详情 + :param recommendation: 处理建议 + :param fixable: 是否支持 Doctor 自动修复 + :param fixed: 本次运行是否已修复 + :param affects_report_status: 是否参与整体报告状态聚合 + :param context: 可选结构化上下文 + :return: 新增的诊断发现 """ raise NotImplementedError @@ -275,6 +296,7 @@ def _tail_lines(path: Path, max_lines: int = 120, max_bytes: int = 256 * 1024) - def _find_error_lines(lines: list[str], max_matches: int = 12) -> list[str]: + """从近期日志中提取错误关键词命中的行。""" matches: list[str] = [] for line in lines: if any(pattern.search(line) for pattern in LOG_ERROR_PATTERNS): @@ -282,6 +304,39 @@ def _find_error_lines(lines: list[str], max_matches: int = 12) -> list[str]: return matches[-max_matches:] +def _partition_error_lines( + lines: list[str], + plugin_logger_names: set[str], + max_matches: int = 12, +) -> tuple[list[str], list[str]]: + """ + 将主日志错误线索拆分为核心错误和插件子系统错误。 + + :param lines: 近期日志行 + :param plugin_logger_names: 已发现的插件控制台 logger 名称 + :param max_matches: 每类最多保留的错误行数 + :return: 核心错误行和插件错误行 + """ + core_matches: list[str] = [] + plugin_matches: list[str] = [] + plugin_context = False + for line in lines: + if LOG_RECORD_PATTERN.search(line): + logger_match = CONSOLE_LOGGER_PATTERN.search(line) + console_logger = logger_match.group(1).strip().lower() if logger_match else "" + plugin_context = ( + console_logger in plugin_logger_names + or any(pattern.search(line) for pattern in PLUGIN_ERROR_PATTERNS) + ) + if not any(pattern.search(line) for pattern in LOG_ERROR_PATTERNS): + continue + if plugin_context or any(pattern.search(line) for pattern in PLUGIN_ERROR_PATTERNS): + plugin_matches.append(line) + else: + core_matches.append(line) + return core_matches[-max_matches:], plugin_matches[-max_matches:] + + def _frontend_dir() -> Path: root_public = settings.ROOT_PATH / "public" configured = Path(settings.FRONTEND_PATH) @@ -687,14 +742,18 @@ def _check_frontend_assets(runner: DoctorRunnerProtocol) -> None: def _check_logs(runner: DoctorRunnerProtocol) -> None: + """扫描近期日志,并区分核心运行异常与插件扩展异常。""" log_files = [ _backend_app_log_file(), _backend_stdio_log_file(), _frontend_stdio_log_file(), ] plugin_log_dir = settings.LOG_PATH / "plugins" + plugin_logger_names: set[str] = set() if plugin_log_dir.exists(): - log_files.extend(sorted(plugin_log_dir.rglob("*.log"))[:20]) + plugin_log_files = sorted(plugin_log_dir.rglob("*.log")) + plugin_logger_names = {path.stem.lower() for path in plugin_log_files} + log_files.extend(plugin_log_files[:20]) found_any = False for path in log_files: @@ -702,23 +761,44 @@ def _check_logs(runner: DoctorRunnerProtocol) -> None: continue found_any = True lines = _tail_lines(path) - errors = _find_error_lines(lines) - if not errors: + is_plugin_log = plugin_log_dir in path.parents + if is_plugin_log: + scoped_errors = [(True, _find_error_lines(lines))] + else: + core_errors, plugin_errors = _partition_error_lines( + lines, + plugin_logger_names, + ) + scoped_errors = [(False, core_errors), (True, plugin_errors)] + if not any(errors for _, errors in scoped_errors): continue - is_plugin = plugin_log_dir in path.parents - runner.add( - finding_id=f"logs.{path.stem}.recent_errors", - severity=DoctorSeverity.Warn, - status=DoctorFindingStatus.Degraded, - title="最近日志存在插件异常" if is_plugin else "最近日志存在错误线索", - detail="\n".join(errors), - recommendation=( - "可使用安全模式启动后检查插件配置。" - if is_plugin - else "结合前后的启动日志定位异常;必要时执行 `moviepilot doctor --json` 交给 Agent 或 Issue 流程。" - ), - context={"log_file": str(path), "matches": len(errors)}, - ) + has_core_errors = bool(scoped_errors[0][1]) if not is_plugin_log else False + for is_plugin_error, errors in scoped_errors: + if not errors: + continue + finding_suffix = ( + "plugin_errors" + if is_plugin_error and has_core_errors + else "recent_errors" + ) + runner.add( + finding_id=f"logs.{path.stem}.{finding_suffix}", + severity=DoctorSeverity.Warn, + status=DoctorFindingStatus.Degraded, + title="最近日志存在插件异常" if is_plugin_error else "最近日志存在错误线索", + detail="\n".join(errors), + recommendation=( + "可使用安全模式启动后检查插件配置。" + if is_plugin_error + else "结合前后的启动日志定位异常;必要时执行 `moviepilot doctor --json` 交给 Agent 或 Issue 流程。" + ), + affects_report_status=not is_plugin_error, + context={ + "log_file": str(path), + "matches": len(errors), + "component": "plugin" if is_plugin_error else "core", + }, + ) if not found_any: runner.add( diff --git a/app/doctor/formatters.py b/app/doctor/formatters.py index 88af8f6d6..8c1dcd722 100644 --- a/app/doctor/formatters.py +++ b/app/doctor/formatters.py @@ -47,6 +47,8 @@ def _format_finding(finding: DoctorFinding) -> list[str]: marker = finding.severity.value.upper() if finding.fixed: marker = "FIXED" + elif not finding.affects_report_status: + marker = f"{marker}/ADVISORY" lines = [f"[{marker}] {finding.title}", f"ID: {finding.id}"] if finding.detail: lines.append(f"原因: {finding.detail}") diff --git a/app/doctor/models.py b/app/doctor/models.py index 7bc189dec..0aadcbb96 100644 --- a/app/doctor/models.py +++ b/app/doctor/models.py @@ -52,6 +52,7 @@ class DoctorFinding: recommendation: str fixable: bool = False fixed: bool = False + affects_report_status: bool = True context: dict[str, Any] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: @@ -67,6 +68,7 @@ class DoctorFinding: "recommendation": self.recommendation, "fixable": self.fixable, "fixed": self.fixed, + "affects_report_status": self.affects_report_status, } if self.context: payload["context"] = self.context @@ -90,7 +92,11 @@ class DoctorReport: """ 根据诊断发现计算整体状态。 """ - unresolved = [finding for finding in self.findings if not finding.fixed] + unresolved = [ + finding + for finding in self.findings + if not finding.fixed and finding.affects_report_status + ] if any(finding.severity == DoctorSeverity.Error for finding in unresolved): return DoctorReportStatus.Failed if any(finding.severity == DoctorSeverity.Warn for finding in unresolved): diff --git a/app/doctor/runner.py b/app/doctor/runner.py index 74b38cc2d..f759c4302 100644 --- a/app/doctor/runner.py +++ b/app/doctor/runner.py @@ -67,10 +67,23 @@ class DoctorRunner: recommendation: str, fixable: bool = False, fixed: bool = False, + affects_report_status: bool = True, context: Optional[dict[str, Any]] = None, ) -> DoctorFinding: """ 添加诊断发现并返回该对象。 + + :param finding_id: 诊断项稳定标识 + :param severity: 诊断严重级别 + :param status: 单项诊断状态 + :param title: 诊断项标题 + :param detail: 诊断详情 + :param recommendation: 处理建议 + :param fixable: 是否支持 Doctor 自动修复 + :param fixed: 本次运行是否已修复 + :param affects_report_status: 是否参与整体报告状态聚合 + :param context: 可选结构化上下文 + :return: 新增的诊断发现 """ finding = DoctorFinding( id=finding_id, @@ -81,6 +94,7 @@ class DoctorRunner: recommendation=recommendation, fixable=fixable, fixed=fixed, + affects_report_status=affects_report_status, context=context or {}, ) self.report.add_finding(finding) @@ -88,6 +102,7 @@ class DoctorRunner: @staticmethod def _environment() -> dict[str, Any]: + """收集 Doctor 报告所需的本地运行环境信息。""" return { "runtime": "Docker" if SystemUtils.is_docker() else platform.system(), "platform": platform.platform(), diff --git a/docs/cli.md b/docs/cli.md index 9a9a88d20..d3eaf72bc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -392,6 +392,7 @@ moviepilot doctor --deep - `--json` 输出稳定 JSON,可供 Agent、脚本或 Issue 流程收集 - `--fix` 只执行白名单安全修复,例如清理过期 runtime 文件或补齐不合法的 `API_TOKEN` - `--deep` 执行可能较慢的深度探测,例如 PostgreSQL TCP 连通性检查 +- 插件日志异常会保留为诊断告警并标记 `affects_report_status=false`,但不会单独降低系统整体状态;核心错误仍正常参与状态聚合 - Docker 环境可使用 `docker exec moviepilot doctor`;如果容器已退出,也可用镜像挂载同一配置目录运行 `python -m app.cli doctor` 日志: diff --git a/docs/doctor.md b/docs/doctor.md index 54bd46082..36c9c6297 100644 --- a/docs/doctor.md +++ b/docs/doctor.md @@ -46,6 +46,8 @@ Doctor 默认执行只读检查: `--deep` 会启用可能较慢或更依赖环境的检查,例如 PostgreSQL TCP 连通性。 +整体状态只聚合会影响 MoviePilot 核心运行的诊断项。插件独立日志以及主日志中可明确识别的插件子系统异常仍会作为 `warn/degraded` 诊断项保留,但其 `affects_report_status` 为 `false`,不会单独把整体状态从 `healthy` 降为 `degraded`;同一日志中若还存在核心错误,核心错误仍会参与状态聚合。 + ## 自救能力 `moviepilot doctor --fix` 只做白名单安全修复: diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 844c3bc89..0b6770ab8 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -243,6 +243,10 @@ Agent 自主任务工具使用数据库中的整数 `task_id`。`query_scheduler ] ``` +#### 系统诊断工具 + +`query_doctor_report` 以只读方式返回 MoviePilot Doctor 诊断报告,可通过 `deep` 启用深度检查,并通过 `include_details` 控制是否返回完整详情。每条诊断项的 `affects_report_status` 表示其是否参与整体状态聚合;插件日志异常会保留为 `warn/degraded` 线索,但该字段为 `false`,不会单独把系统整体状态降为 `degraded`。 + ### 2. 调用工具 **POST** `/api/v1/mcp/tools/call` diff --git a/moviepilot b/moviepilot index 27aad3133..7dfed8880 100755 --- a/moviepilot +++ b/moviepilot @@ -249,6 +249,7 @@ Options: 说明: - doctor 是离线诊断入口,不依赖后端服务已经启动 + - 插件日志异常会保留为告警,但不会单独降低系统整体状态 - Docker 环境可执行 docker exec moviepilot doctor EOF } diff --git a/skills/feedback-issue/SKILL.md b/skills/feedback-issue/SKILL.md index e9f06e25c..c5cb910d1 100644 --- a/skills/feedback-issue/SKILL.md +++ b/skills/feedback-issue/SKILL.md @@ -120,7 +120,9 @@ you need to show the preview generated in the next step. The collect script also runs `moviepilot doctor --json` or falls back to `python -m app.cli doctor --json`, stores the structured doctor report inside `diagnostics_file`, and later preview/submit steps include a -short doctor summary automatically. +short doctor summary automatically. Plugin-only log findings remain in +the report as diagnostic evidence with `affects_report_status=false`, so +they do not by themselves downgrade the overall MoviePilot status. If `success=false` with `no_explicit_feedback_intent`, stop this skill and return to local diagnosis. diff --git a/skills/moviepilot-cli/SKILL.md b/skills/moviepilot-cli/SKILL.md index d5d01962c..9a5de5dd2 100644 --- a/skills/moviepilot-cli/SKILL.md +++ b/skills/moviepilot-cli/SKILL.md @@ -260,5 +260,6 @@ Use `air_date` to find a block of recently-aired episodes that likely correspond ## Error handling Missing configuration or authentication failure: run `moviepilot doctor` to -verify the local MoviePilot installation and settings. Do not ask the user to -paste the API key into the prompt for local CLI usage. +verify the local MoviePilot installation and settings. Plugin-only log findings +remain visible but do not by themselves downgrade the overall Doctor status. +Do not ask the user to paste the API key into the prompt for local CLI usage. diff --git a/tests/test_agent_doctor_tool.py b/tests/test_agent_doctor_tool.py index c54ae1876..b747729b9 100644 --- a/tests/test_agent_doctor_tool.py +++ b/tests/test_agent_doctor_tool.py @@ -71,6 +71,7 @@ def test_query_doctor_report_returns_readonly_report(): assert payload["report"]["status"] == "degraded" assert payload["report"]["environment"]["runtime"] == "Docker" assert payload["report"]["findings"][0]["detail"] == "ERROR demo Cookie: " + assert payload["report"]["findings"][0]["affects_report_status"] is True run_doctor.assert_called_once_with(deep=True) @@ -88,6 +89,7 @@ def test_query_doctor_report_compact_mode_omits_details(): finding = payload["report"]["findings"][0] assert finding["id"] == "logs.moviepilot.recent_errors" assert finding["title"] == "最近日志存在错误线索" + assert finding["affects_report_status"] is True assert "detail" not in finding assert "context" not in finding diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 70c6e31b7..e6575397d 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -23,6 +23,7 @@ def test_doctor_report_has_stable_json_shape(tmp_path, monkeypatch): assert payload["environment"]["config_path"] == str(tmp_path) assert isinstance(payload["summary"]["total"], int) assert isinstance(payload["findings"], list) + assert all("affects_report_status" in item for item in payload["findings"]) assert any(item["id"] == "runtime.paths" for item in payload["findings"]) @@ -86,3 +87,130 @@ def test_doctor_accepts_healthy_unmanaged_backend_port(monkeypatch): assert finding.severity == DoctorSeverity.Info assert finding.context["backend_version"] == "v2-test" assert runner.report.find("port.backend_occupied") is None + + +def test_doctor_plugin_log_error_does_not_degrade_report(tmp_path, monkeypatch): + """插件独立日志中的错误应保留告警,但不降低系统整体状态。""" + monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path)) + plugin_log = settings.LOG_PATH / "plugins" / "demo.log" + plugin_log.parent.mkdir(parents=True, exist_ok=True) + plugin_log.write_text( + "【ERROR】2026-07-20 08:00:00 - demo.py - 插件任务执行异常\n", + encoding="utf-8", + ) + + runner = DoctorRunner() + checks._check_logs(runner) + + finding = runner.report.find("logs.demo.recent_errors") + assert finding is not None + assert finding.status == DoctorFindingStatus.Degraded + assert finding.severity == DoctorSeverity.Warn + assert finding.affects_report_status is False + assert finding.context["component"] == "plugin" + assert runner.report.status.value == "healthy" + assert "[WARN/ADVISORY]" in format_text_report(runner.report) + + +def test_doctor_plugin_load_error_in_main_log_does_not_degrade_report( + tmp_path, + monkeypatch, +): + """主日志中的插件加载异常应归入插件告警,不降低系统整体状态。""" + monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path)) + app_log = settings.LOG_PATH / "moviepilot.log" + app_log.parent.mkdir(parents=True, exist_ok=True) + app_log.write_text( + "【ERROR】2026-07-20 08:00:00 - plugin.py - 加载插件 Demo 出错:boom - Traceback (most recent call last):\n" + "Exception: boom\n", + encoding="utf-8", + ) + + runner = DoctorRunner() + checks._check_logs(runner) + + finding = runner.report.find("logs.moviepilot.recent_errors") + assert finding is not None + assert finding.affects_report_status is False + assert finding.context["component"] == "plugin" + assert "Exception: boom" in finding.detail + assert runner.report.status.value == "healthy" + + +def test_doctor_plugin_error_mirrored_to_stdio_does_not_degrade_report( + tmp_path, + monkeypatch, +): + """插件错误镜像到后端 stdio 日志时仍不应降低系统整体状态。""" + monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path)) + plugin_log = settings.LOG_PATH / "plugins" / "DemoPlugin.log" + plugin_log.parent.mkdir(parents=True, exist_ok=True) + plugin_log.write_text( + "【INFO】2026-07-20 08:00:00 - demo.py - 插件已启动\n", + encoding="utf-8", + ) + stdio_log = settings.LOG_PATH / "moviepilot.stdout.log" + stdio_log.write_text( + "ERROR: [demoplugin] 2026-07-20 08:01:00 demo.py - task exception\n", + encoding="utf-8", + ) + + runner = DoctorRunner() + checks._check_logs(runner) + + finding = runner.report.find("logs.moviepilot.stdout.recent_errors") + assert finding is not None + assert finding.affects_report_status is False + assert finding.context["component"] == "plugin" + assert runner.report.status.value == "healthy" + + +def test_doctor_core_log_error_still_degrades_report(tmp_path, monkeypatch): + """核心日志错误仍应参与系统整体状态聚合。""" + monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path)) + app_log = settings.LOG_PATH / "moviepilot.log" + app_log.parent.mkdir(parents=True, exist_ok=True) + app_log.write_text( + "【ERROR】2026-07-20 08:00:00 - rss.py - 解析 RSS 失败 - Traceback (most recent call last):\n" + "RuntimeError: boom\n", + encoding="utf-8", + ) + + runner = DoctorRunner() + checks._check_logs(runner) + + finding = runner.report.find("logs.moviepilot.recent_errors") + assert finding is not None + assert finding.affects_report_status is True + assert finding.context["component"] == "core" + assert runner.report.status.value == "degraded" + + +def test_doctor_mixed_plugin_and_core_log_errors_keep_core_status( + tmp_path, + monkeypatch, +): + """同一主日志混有插件和核心错误时,仅核心错误应影响整体状态。""" + monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path)) + app_log = settings.LOG_PATH / "moviepilot.log" + app_log.parent.mkdir(parents=True, exist_ok=True) + app_log.write_text( + "【ERROR】2026-07-20 08:00:00 - plugin.py - 加载插件 Demo 出错:boom - Traceback (most recent call last):\n" + "Exception: plugin boom\n" + "【ERROR】2026-07-20 08:01:00 - rss.py - 解析 RSS 失败 - Traceback (most recent call last):\n" + "Exception: core boom\n", + encoding="utf-8", + ) + + runner = DoctorRunner() + checks._check_logs(runner) + + core_finding = runner.report.find("logs.moviepilot.recent_errors") + plugin_finding = runner.report.find("logs.moviepilot.plugin_errors") + assert core_finding is not None + assert plugin_finding is not None + assert core_finding.affects_report_status is True + assert plugin_finding.affects_report_status is False + assert "core boom" in core_finding.detail + assert "plugin boom" in plugin_finding.detail + assert runner.report.status.value == "degraded"