fix(recovery): scope incident verification to relevant devices
Generic Container CI/CD / test-build-publish (push) Successful in 4m33s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m33s

Full-network browsing expanded persisted recovery gates to unrelated devices, while full fifteen-day reference reads exhausted the row budget. Rebuild legacy cached scopes from report devices and project monitoring roles, and query only the nearest post-feedback frame and its historical phase.

Regression tests fail before the fix for both new and cached scopes and verify automatic Agent requeue after recovery.
This commit is contained in:
2026-11-15 03:47:55 +08:00
parent 11eb09fe8f
commit 58a907bfbe
5 changed files with 72 additions and 10 deletions
+48
View File
@@ -0,0 +1,48 @@
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from app.domain.schemas.project_settings import ProjectSettings
from app.services.operational_recovery import reconcile_recoveries
from app.services.operational_tasks import OperationalTaskService
from tests.support.operational_task_repository import TaskRepository, _active_run, _context, _task_document
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.anyio
@pytest.mark.parametrize("cached", [False, True])
async def test_recovery_uses_report_devices_instead_of_all_browsed_network_devices(monkeypatch, cached):
now = datetime(2026, 7, 16, 6, tzinfo=timezone.utc)
incident = now - timedelta(hours=6)
returned = now - timedelta(minutes=20)
task = _task_document(_context(), now, lease=now + timedelta(minutes=1))
task.update(workflow="scada_anomaly", status="waiting_human", lifecycle_status="waiting_human",
current_report_revision=1, trigger={"kind": "event", "occurred_at": incident.isoformat(), "detail": "异常"},
handling_context={"phase": "waiting_scada", "returned_at": returned.isoformat()})
if cached:
task["handling_context"].update(device_ids=["P1", "unrelated"], interval_seconds=900)
repo = TaskRepository(task, _active_run(task, now))
repo.get_latest_report = AsyncMock(return_value={"revision": 1, "report": {"scada_device_ids": ["P1"]}})
repo.list_evidence = AsyncMock(return_value=[{"data_snapshot": {"source": "query_scada_readings",
"data": {"data": [{"device_id": "P1"}, {"device_id": "unrelated"}]}}}])
repo.recovery_candidates = AsyncMock(side_effect=lambda *_: [deepcopy(repo.task)]
if repo.task["handling_context"]["phase"] == "waiting_scada" else [])
service = OperationalTaskService(repo, _context(), set(), clock=lambda: now)
at = returned + timedelta(minutes=5)
rows = [{"time": at - timedelta(days=day), "device_id": "P1", "monitored_value": 100.}
for day in range(1, 16)] + [{"time": at, "device_id": "P1", "monitored_value": 100.}]
reader = AsyncMock(side_effect=lambda _, devices, *args, **kwargs: rows if devices == ["P1"] else [{}] * 100_001)
monkeypatch.setattr("app.infra.db.timescaledb.repositories.scada.ScadaRepository.get_recovery_scada_readings", reader)
settings = ProjectSettings(scada_monitoring_interval_seconds=900, scada_monitoring={"pressure_device_ids": ["P1"]})
assert await reconcile_recoveries(service, SimpleNamespace(transaction=repo.transaction), settings) == 1
assert repo.task["handling_context"]["device_ids"] == ["P1"]
assert repo.task["handling_context"]["phase"] == "verification_ready"
assert repo.run["status"] == "queued"
assert await reconcile_recoveries(service, SimpleNamespace(transaction=repo.transaction), settings) == 0