From 468eba526e520fa7eab445cf4748cca4e8d780ac Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 3 Jul 2026 07:53:18 +0800 Subject: [PATCH] fix(awooop): fail soft drift fingerprint readback --- apps/api/src/api/v1/drift.py | 15 +++++ .../drift_fingerprint_state_service.py | 61 +++++++++++++++++++ .../tests/test_drift_fingerprint_state_api.py | 31 ++++++++++ .../test_drift_fingerprint_state_service.py | 23 +++++++ docs/LOGBOOK.md | 17 ++++++ 5 files changed, 147 insertions(+) create mode 100644 apps/api/tests/test_drift_fingerprint_state_api.py diff --git a/apps/api/src/api/v1/drift.py b/apps/api/src/api/v1/drift.py index aa973bbeb..dc1393dbb 100644 --- a/apps/api/src/api/v1/drift.py +++ b/apps/api/src/api/v1/drift.py @@ -15,6 +15,7 @@ leWOOOgo 積木化原則: from typing import Literal +import structlog from fastapi import APIRouter, BackgroundTasks, HTTPException from pydantic import BaseModel, Field @@ -32,6 +33,7 @@ from src.services.drift_analyzer import get_drift_analyzer from src.services.drift_detector import get_drift_detector from src.services.drift_fingerprint_state_service import ( DriftFingerprintStateNotFoundError, + build_drift_fingerprint_unavailable_state, get_drift_fingerprint_state_service, ) from src.services.drift_interpreter import get_drift_interpreter @@ -39,6 +41,7 @@ from src.services.drift_remediator import get_drift_remediator from src.utils.timezone import now_taipei router = APIRouter(prefix="/drift", tags=["drift"]) +logger = structlog.get_logger(__name__) # 2026-04-09 Claude Sonnet 4.6: B4 drift_reports 持久化 — 改用 DB repository @@ -157,6 +160,18 @@ async def get_drift_fingerprint_state( return await svc.get_state(report_id=report_id, namespace=namespace) except DriftFingerprintStateNotFoundError as exc: raise HTTPException(status_code=404, detail="drift_report_not_found") from exc + except Exception as exc: + logger.warning( + "drift_fingerprint_state_readback_degraded", + report_id=report_id, + namespace=namespace, + error_type=type(exc).__name__, + ) + return build_drift_fingerprint_unavailable_state( + namespace=namespace, + report_id=report_id, + reason="internal_readback_error", + ) @router.post("/fingerprints/handoff", summary="記錄 Config Drift fingerprint 交接") diff --git a/apps/api/src/services/drift_fingerprint_state_service.py b/apps/api/src/services/drift_fingerprint_state_service.py index 231a4e409..eb6597dac 100644 --- a/apps/api/src/services/drift_fingerprint_state_service.py +++ b/apps/api/src/services/drift_fingerprint_state_service.py @@ -233,6 +233,67 @@ def build_drift_fingerprint_state( } +def build_drift_fingerprint_unavailable_state( + *, + namespace: str | None = None, + report_id: str | None = None, + reason: str = "readback_exception", +) -> dict[str, Any]: + """Build a no-write degraded payload when the read model cannot load.""" + + selected_namespace = namespace or DEFAULT_NAMESPACE + return { + "schema_version": SCHEMA_VERSION, + "namespace": selected_namespace, + "fingerprint": None, + "latest_report_id": report_id, + "latest_status": "readback_unavailable", + "latest_scanned_at": None, + "latest_created_at": None, + "summary": "Drift fingerprint readback unavailable; AI controlled retry required", + "high_count": 0, + "medium_count": 0, + "info_count": 0, + "interpretation": None, + "repeat_state": { + "fingerprint": None, + "occurrences_12h": 0, + "matching_strategy": "readback_degraded_fallback_v1", + }, + "occurrences_12h": 0, + "matching_strategy": "readback_degraded_fallback_v1", + "operator_stage": "readback_degraded_ai_controlled_repair", + "fsm_state": "pending_human", + "next_step": "manual_investigation_or_ansible_check_mode", + "open_pr": None, + "latest_handoff": None, + "latest_remediation": None, + "strict_fingerprint": None, + "p0_escalation": { + "suppresses_repeated_p0": False, + "dedup_key_strategy": "readback_degraded_no_fingerprint", + "dedup_window_hours": 24, + }, + "read_model_route": { + "agent_id": "openclaw", + "tool_name": "drift_fingerprint_state", + "required_scope": "read:drift read:gitea", + "flywheel_node": ( + "drift_scanned>ai_analyzed>fingerprint_fsm>" + "ai_controlled_readback_repair" + ), + }, + "readback_status": "degraded", + "readback_error": reason, + "writes_incident_state": False, + "writes_auto_repair_result": False, + "writes_drift_status": False, + "writes_ticket": False, + "writes_remediation_record": False, + "creates_external_ticket": False, + } + + def _handoff_context( state: dict[str, Any], *, diff --git a/apps/api/tests/test_drift_fingerprint_state_api.py b/apps/api/tests/test_drift_fingerprint_state_api.py new file mode 100644 index 000000000..6e9681e29 --- /dev/null +++ b/apps/api/tests/test_drift_fingerprint_state_api.py @@ -0,0 +1,31 @@ +import pytest + +from src.api.v1 import drift as drift_api + + +class _BrokenDriftFingerprintStateService: + async def get_state(self, *, report_id=None, namespace=None): + raise RuntimeError("simulated drift readback failure") + + +@pytest.mark.asyncio +async def test_drift_fingerprint_state_endpoint_fails_soft_on_readback_error(monkeypatch) -> None: + monkeypatch.setattr( + drift_api, + "get_drift_fingerprint_state_service", + lambda: _BrokenDriftFingerprintStateService(), + ) + + payload = await drift_api.get_drift_fingerprint_state( + report_id=None, + namespace="awoooi-prod", + ) + + assert payload["schema_version"] == "drift_fingerprint_state_v1" + assert payload["namespace"] == "awoooi-prod" + assert payload["readback_status"] == "degraded" + assert payload["readback_error"] == "internal_readback_error" + assert payload["operator_stage"] == "readback_degraded_ai_controlled_repair" + assert payload["writes_incident_state"] is False + assert payload["writes_auto_repair_result"] is False + assert payload["writes_drift_status"] is False diff --git a/apps/api/tests/test_drift_fingerprint_state_service.py b/apps/api/tests/test_drift_fingerprint_state_service.py index ddc7627e7..170995b60 100644 --- a/apps/api/tests/test_drift_fingerprint_state_service.py +++ b/apps/api/tests/test_drift_fingerprint_state_service.py @@ -1,6 +1,7 @@ from src.models.drift import DriftItem, DriftLevel, DriftReport, DriftStatus from src.services.drift_fingerprint_state_service import ( build_drift_fingerprint_state, + build_drift_fingerprint_unavailable_state, ) from src.services.drift_repeat_state import build_drift_repeat_state @@ -128,3 +129,25 @@ def test_build_state_marks_remediation_executed_unverified() -> None: assert state["fsm_state"] == "remediation_executed_unverified" assert state["next_step"] == "run_verification_scan_then_record_result" + + +def test_build_unavailable_state_is_no_write_ai_controlled_retry() -> None: + state = build_drift_fingerprint_unavailable_state( + namespace="awoooi-prod", + report_id="drift-readback-gap", + reason="internal_readback_error", + ) + + assert state["schema_version"] == "drift_fingerprint_state_v1" + assert state["namespace"] == "awoooi-prod" + assert state["latest_report_id"] == "drift-readback-gap" + assert state["readback_status"] == "degraded" + assert state["readback_error"] == "internal_readback_error" + assert state["operator_stage"] == "readback_degraded_ai_controlled_repair" + assert state["fsm_state"] == "pending_human" + assert state["next_step"] == "manual_investigation_or_ansible_check_mode" + assert state["writes_incident_state"] is False + assert state["writes_auto_repair_result"] is False + assert state["writes_drift_status"] is False + assert state["writes_ticket"] is False + assert state["creates_external_ticket"] is False diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 0f1ec0df6..c0a175406 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,20 @@ +## 2026-07-03 — 07:55 Drift fingerprint readback degraded fallback + +**完成內容**: +- Runs AI Loop Agent 正式 deploy 後,production mobile smoke 擴查 `/zh-TW/awooop/work-items` 時發現 `GET /api/v1/drift/fingerprints/state?namespace=awoooi-prod&project_id=awoooi` 回 HTTP 500,導致相關頁 smoke 出現 console resource error。 +- `/api/v1/drift/fingerprints/state` 保留 `DriftFingerprintStateNotFoundError` → 404;其他 readback exception 改回 `drift_fingerprint_state_v1` degraded payload,標示 `readback_status=degraded`、`operator_stage=readback_degraded_ai_controlled_repair`、`next_step=manual_investigation_or_ansible_check_mode`,讓 Work Items / AI Agent 顯示 AI 受控補齊路徑,不再把 readback gap 變成 Internal Error。 +- degraded payload 固定 no-write:不寫 incident、auto-repair、drift status、ticket、remediation record,也不建立外部 ticket。 + +**已跑驗證**: +- `DATABASE_URL=postgresql+asyncpg://test:test@localhost/test PYTHONPATH=apps/api python3.11 -m pytest apps/api/tests/test_drift_fingerprint_state_service.py apps/api/tests/test_drift_fingerprint_state_api.py -q -p no:cacheprovider`:`7 passed`。 +- `python3.11 -m py_compile apps/api/src/api/v1/drift.py apps/api/src/services/drift_fingerprint_state_service.py apps/api/tests/test_drift_fingerprint_state_service.py apps/api/tests/test_drift_fingerprint_state_api.py`:通過。 +- `python3.11 ops/runner/guard-gitea-runner-pressure.py --root .`:通過。 +- `git diff --check`:通過。 +- production Work Items desktop/mobile smoke:待本 commit deploy marker 後重跑。 + +**仍維持**: +- 本輪只修 read-only API fallback;不讀 secret / token / `.env` / raw sessions / SQLite / auth;不使用 GitHub / gh;不觸發 drift scan、不寫 DB、不發 Telegram、不做 runtime apply;critical / secret / destructive / reboot / firewall / active scan / paid provider / force push 仍維持 break-glass。 + ## 2026-07-03 — 05:05 AwoooP Runs AI Loop Agent 處置鏈首屏 **完成內容**: