diff --git a/apps/api/src/services/auto_repair_service.py b/apps/api/src/services/auto_repair_service.py index 974e397ce..879e562bf 100644 --- a/apps/api/src/services/auto_repair_service.py +++ b/apps/api/src/services/auto_repair_service.py @@ -1057,6 +1057,38 @@ class AutoRepairService: required_scope="write", ) + def _upstream_repair_success_result( + self, + incident: Incident, + command: str, + ) -> str | None: + """Treat trusted upstream host repair labels as a successful no-op. + + Some host monitors already perform the Docker restart before sending the + alert. Re-running the restart from AwoooP would create duplicate + disruption; the flywheel should record that the execute stage converged + and leave evidence in the execution row instead. + """ + + if "docker restart" not in (command or "").lower(): + return None + + labels = self._incident_labels(incident) + repair_result = str(labels.get("repair_result") or "").strip().lower() + repair_action = str(labels.get("repair_action") or "").strip().lower() + if repair_result not in {"success", "succeeded", "ok"}: + return None + if repair_action not in {"restart", "restarted", "docker_restart", "docker-restart"}: + return None + + container_name = self._resolve_container_name_for_incident(incident, command) + source = str(labels.get("source") or "upstream_monitor").strip() or "upstream_monitor" + target = f" container={container_name}" if container_name else "" + return ( + "SUCCESS: upstream repair already completed " + f"(source={source} action={repair_action} result={repair_result}{target})" + ) + def preview_read_only_ssh_mcp_route( self, incident: Incident, @@ -1313,6 +1345,10 @@ class AutoRepairService: # 2026-04-06 Claude Code: Sprint 3 — repair_by_uri (URI scheme 路由) if step.action_type == ActionType.SSH_COMMAND: + upstream_success = self._upstream_repair_success_result(incident, step.command) + if upstream_success is not None: + return upstream_success + route = self._route_legacy_ssh_command_to_mcp(incident, step.command) if route is not None: return await self._execute_ssh_mcp_route(incident, route) diff --git a/apps/api/tests/test_auto_repair_service.py b/apps/api/tests/test_auto_repair_service.py index 08b651f0b..29a7209aa 100644 --- a/apps/api/tests/test_auto_repair_service.py +++ b/apps/api/tests/test_auto_repair_service.py @@ -497,6 +497,44 @@ class TestAutoRepairService: assert route is None + @pytest.mark.asyncio + async def test_execute_legacy_ssh_restart_noops_when_upstream_repair_succeeded( + self, + service, + monkeypatch, + ): + """Do not restart twice when the host monitor already repaired it.""" + incident = create_test_incident( + severity=Severity.P2, + alert_category="infrastructure", + alert_name="DockerContainerUnhealthy", + ) + incident.signals[0].labels.update({ + "host": "wooo", + "container": "stockplatform-v2-edge-1", + "source": "docker-health-monitor", + "repair_action": "restarted", + "repair_result": "success", + }) + step = RepairStep( + step_number=1, + action_type=ActionType.SSH_COMMAND, + command='ssh {host} \'docker inspect {container} --format="{{.State.Health.Status}}" && docker restart {container}\'', + risk_level=RiskLevel.MEDIUM, + requires_approval=False, + ) + + async def fail_if_called(*args, **kwargs): + raise AssertionError("MCP Gateway should not be called after upstream repair success") + + monkeypatch.setattr(service, "_execute_ssh_mcp_route", fail_if_called) + + result = await service._execute_step(incident, step) + + assert result.startswith("SUCCESS: upstream repair already completed") + assert "docker-health-monitor" in result + assert "stockplatform-v2-edge-1" in result + @pytest.mark.asyncio async def test_execute_legacy_ssh_diagnostic_uses_mcp_gateway( self, diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 2fa1af578..7c0c5e3eb 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -46,12 +46,13 @@ - 新增安全 legacy SSH write 路由:只允許簡單 `docker restart ` 或 `{container}` placeholder,且必須能解析出安全 container name。 - 將該路由轉進 AwoooP MCP Gateway `ssh_docker_restart/write`,注入 `trust_score=0.85`、MCP audit context、`auto_repair_executor` agent 與 deterministic run id。 - 對 write scope 先投影短效 Gate 5 key `approved:auto_repair_policy`;`requires_approval=true` 時仍 fail-closed,不會繞過人工審批。 + - 若 alert labels 已明確標示 `repair_action=restarted` 且 `repair_result=success`,視為上游 host monitor 已完成修復,execute stage 記成功 no-op,避免二次 Docker restart。 - 保留 read-only `ssh_diagnose` 原路徑;含 command substitution、pipe、fallback shell、systemd/prune 的複雜命令仍拒絕自動寫入。 - `apps/api/migrations/awooop_awoooi_mcp_auto_repair_executor_docker_restart_2026-06-01.sql` - 將 `auto_repair_executor` agent contract 升到 v1.1。 - 僅新增 `ssh_docker_restart/write` grant,邊界寫入 contract:只允許安全 Docker container restart,其他寫入工具仍不授權。 - `apps/api/tests/test_auto_repair_service.py` - - 補上 write MCP route、複雜 shell 封鎖與 Gate 5 approval projection 測試。 + - 補上 write MCP route、上游已修復 no-op、複雜 shell 封鎖與 Gate 5 approval projection 測試。 **驗證**: @@ -60,7 +61,7 @@ PYTHONPATH=apps/api python -m py_compile apps/api/src/services/auto_repair_servi DATABASE_URL=sqlite+aiosqlite:///:memory: PYTHONPATH=apps/api python -m pytest apps/api/tests/test_auto_repair_service.py -q → 26 passed DATABASE_URL=sqlite+aiosqlite:///:memory: PYTHONPATH=apps/api python -m pytest apps/api/tests/test_auto_repair_service.py apps/api/tests/test_approval_execution_mcp_audit.py -q -→ 29 passed +→ 30 passed production owner-fallback rollback check: apps/api/migrations/awooop_awoooi_mcp_auto_repair_executor_docker_restart_2026-06-01.sql → migration rollback syntax ok with owner fallback