fix(agent): reconcile terminal incident read models
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m20s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled
AI 技術雷達監控 / ai-technology-watch (push) Successful in 41s

This commit is contained in:
ogt
2026-07-11 19:56:24 +08:00
parent 8a0c916d82
commit 155ea47958
4 changed files with 261 additions and 3 deletions

View File

@@ -14,7 +14,10 @@ import random
import structlog
from src.core.config import settings
from src.services.awooop_ansible_check_mode_service import run_pending_check_modes_once
from src.services.awooop_ansible_check_mode_service import (
reconcile_verified_incident_working_memory_once,
run_pending_check_modes_once,
)
logger = structlog.get_logger(__name__)
_ERROR_BACKOFF_MIN_SECONDS = 15.0
@@ -45,9 +48,39 @@ async def run_awooop_ansible_check_mode_loop() -> None:
limit=settings.AWOOOP_ANSIBLE_CHECK_MODE_BATCH_LIMIT,
timeout_seconds=settings.AWOOOP_ANSIBLE_CHECK_MODE_TIMEOUT_SECONDS,
)
projection = await reconcile_verified_incident_working_memory_once(
limit=max(
1,
min(settings.AWOOOP_ANSIBLE_CHECK_MODE_BATCH_LIMIT * 2, 10),
),
)
result.update(
{
"working_memory_projection_scanned": int(
projection.get("scanned") or 0
),
"working_memory_projection_updated": int(
projection.get("updated") or 0
),
"working_memory_projection_missing": int(
projection.get("missing") or 0
),
"working_memory_projection_failed": int(
projection.get("failed") or 0
),
"working_memory_projection_error": (
str(projection.get("error") or "")[:500] or None
),
}
)
if result.get("error") or result.get("catalog_replay_error"):
sleep_seconds = _error_backoff_seconds()
if result.get("claimed") or result.get("blockers"):
if (
result.get("claimed")
or result.get("blockers")
or result.get("working_memory_projection_updated")
or result.get("working_memory_projection_error")
):
logger.info("awooop_ansible_check_mode_worker_tick", **result)
except Exception as exc:
logger.warning("awooop_ansible_check_mode_worker_failed", error=str(exc))

View File

@@ -2259,6 +2259,99 @@ async def _read_verified_apply_closure_prerequisites(
}
async def _project_incident_terminal_to_working_memory(
*,
incident_id: str,
project_id: str,
incident_status: str,
updated_at: datetime,
resolved_at: datetime | None,
) -> str:
from src.models.incident import IncidentStatus
from src.services.incident_service import get_incident_service
normalized_status = str(incident_status or "").strip().lower()
if normalized_status not in {
IncidentStatus.RESOLVED.value,
IncidentStatus.CLOSED.value,
}:
return "failed"
return await get_incident_service().project_terminal_status_to_working_memory(
incident_id,
project_id=project_id,
status=IncidentStatus(normalized_status),
updated_at=updated_at,
resolved_at=resolved_at,
)
async def reconcile_verified_incident_working_memory_once(
*,
project_id: str = "awoooi",
window_hours: int = 24,
limit: int = 10,
) -> dict[str, Any]:
"""Repair terminal DB truth that is still active in Redis working memory."""
stats: dict[str, Any] = {
"scanned": 0,
"updated": 0,
"current": 0,
"missing": 0,
"failed": 0,
"error": None,
}
try:
async with get_db_context(project_id) as db:
result = await db.execute(
text("""
SELECT
incident.incident_id,
incident.status::text AS incident_status,
incident.updated_at,
incident.resolved_at
FROM incidents incident
WHERE incident.project_id = :project_id
AND upper(incident.status::text) IN ('RESOLVED', 'CLOSED')
AND incident.resolved_at IS NOT NULL
AND cast(incident.outcome AS jsonb) #>>
'{automation_terminal,incident_resolved}' = 'true'
AND incident.updated_at >=
NOW() - (:window_hours * INTERVAL '1 hour')
ORDER BY incident.updated_at DESC
LIMIT :limit
"""),
{
"project_id": project_id,
"window_hours": max(1, int(window_hours)),
"limit": max(1, int(limit)),
},
)
rows = [dict(row) for row in result.mappings().all()]
stats["scanned"] = len(rows)
for row in rows:
projection_status = await _project_incident_terminal_to_working_memory(
incident_id=str(row.get("incident_id") or ""),
project_id=project_id,
incident_status=str(row.get("incident_status") or ""),
updated_at=row["updated_at"],
resolved_at=row.get("resolved_at"),
)
if projection_status in stats:
stats[projection_status] += 1
else:
stats["failed"] += 1
except Exception as exc:
stats["error"] = f"{type(exc).__name__}: {exc}"[:500]
logger.warning(
"ansible_incident_working_memory_reconcile_failed",
project_id=project_id,
error_type=type(exc).__name__,
)
return stats
async def _read_incident_closure_readback(
claim: AnsibleCheckModeClaim,
*,
@@ -2633,7 +2726,7 @@ async def _commit_verified_incident_closure(
and row.get("resolved_at") is not None
):
raise RuntimeError("verified_incident_closure_atomic_readback_failed")
return {
closure_result = {
**terminal,
"incident_status": str(row.get("incident_status") or ""),
"incident_row_version": (
@@ -2644,6 +2737,26 @@ async def _commit_verified_incident_closure(
"closure_receipt_written": True,
"resolved_lifecycle_written": True,
}
projection_status = await _project_incident_terminal_to_working_memory(
incident_id=claim.incident_id,
project_id=project_id,
incident_status=closure_result["incident_status"],
updated_at=row["updated_at"],
resolved_at=row.get("resolved_at"),
)
closure_result["working_memory_projection_status"] = projection_status
closure_result["working_memory_projection_ready"] = (
projection_status in {"updated", "current"}
)
if closure_result["working_memory_projection_ready"] is not True:
logger.warning(
"ansible_verified_incident_closure_working_memory_pending",
automation_run_id=automation_run_id,
incident_id=claim.incident_id,
apply_op_id=apply_op_id,
projection_status=projection_status,
)
return closure_result
except Exception as exc:
logger.warning(
"ansible_verified_incident_closure_atomic_write_failed",
@@ -2689,6 +2802,12 @@ async def _finalize_verified_apply_closure(
"resolved_lifecycle",
],
}
if terminal.get("working_memory_projection_ready") is not True:
return {
"status": "working_memory_projection_pending",
"closed": False,
"missing": ["working_memory_terminal_projection"],
}
readback = await _read_incident_closure_readback(
claim,

View File

@@ -10,6 +10,7 @@ from typing import Any
import structlog
from src.core.config import settings
from src.core.redis_client import close_redis_pool, init_redis_pool
from src.db.base import close_db
from src.jobs.awooop_ansible_check_mode_job import (
run_awooop_ansible_check_mode_loop,
@@ -39,6 +40,8 @@ async def _main() -> None:
if not ssh_key_path.is_file() or not known_hosts_path.is_file():
raise RuntimeError("ansible_execution_broker_transport_not_mounted")
await init_redis_pool()
shutdown_event = asyncio.Event()
def _shutdown_handler(signum: int, _frame: Any) -> None:
@@ -90,6 +93,7 @@ async def _main() -> None:
await task
except asyncio.CancelledError:
pass
await close_redis_pool()
await close_db()
_HEALTH_PATH.unlink(missing_ok=True)
_READY_PATH.unlink(missing_ok=True)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import inspect
import json
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from unittest.mock import AsyncMock
import pytest
@@ -42,6 +43,17 @@ class _SequenceDB:
return self._results.pop(0) if self._results else _MappingResult()
class _RowsResult:
def __init__(self, rows: list[dict]) -> None:
self._rows = rows
def mappings(self) -> _RowsResult:
return self
def all(self) -> list[dict]:
return self._rows
def _claim() -> service.AnsibleCheckModeClaim:
return service.AnsibleCheckModeClaim(
op_id="00000000-0000-0000-0000-000000000101",
@@ -224,6 +236,7 @@ async def test_closure_resolves_only_after_durable_readback(
"incident_resolved": True,
"closure_receipt_written": True,
"resolved_lifecycle_written": True,
"working_memory_projection_ready": True,
}
)
monkeypatch.setattr(
@@ -255,6 +268,95 @@ async def test_closure_resolves_only_after_durable_readback(
assert atomic_writer.await_args.kwargs["closure_prerequisite_count"] == 1
@pytest.mark.asyncio
async def test_closure_waits_for_working_memory_projection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
service,
"_read_verified_apply_closure_prerequisites",
AsyncMock(return_value={"ready": True, "receipts": {"all": True}}),
)
monkeypatch.setattr(
service,
"_commit_verified_incident_closure",
AsyncMock(return_value={"working_memory_projection_ready": False}),
)
readback = AsyncMock()
monkeypatch.setattr(service, "_read_incident_closure_readback", readback)
result = await service._finalize_verified_apply_closure(
_claim(),
apply_op_id="00000000-0000-0000-0000-000000000104",
project_id="awoooi",
)
assert result == {
"status": "working_memory_projection_pending",
"closed": False,
"missing": ["working_memory_terminal_projection"],
}
readback.assert_not_awaited()
@pytest.mark.asyncio
async def test_terminal_working_memory_reconciler_projects_durable_rows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
updated_at = datetime(2026, 7, 11, 11, 45, tzinfo=UTC)
db = _SequenceDB(
_RowsResult(
[
{
"incident_id": "INC-20260711-C50677",
"incident_status": "RESOLVED",
"updated_at": updated_at,
"resolved_at": updated_at,
},
{
"incident_id": "INC-20260711-8A28BC",
"incident_status": "RESOLVED",
"updated_at": updated_at,
"resolved_at": updated_at,
},
]
)
)
@asynccontextmanager
async def fake_db_context(_project_id: str):
yield db
project = AsyncMock(side_effect=["updated", "current"])
monkeypatch.setattr(service, "get_db_context", fake_db_context)
monkeypatch.setattr(
service,
"_project_incident_terminal_to_working_memory",
project,
)
result = await service.reconcile_verified_incident_working_memory_once(
project_id="awoooi",
window_hours=24,
limit=6,
)
assert result == {
"scanned": 2,
"updated": 1,
"current": 1,
"missing": 0,
"failed": 0,
"error": None,
}
assert project.await_count == 2
assert db.parameters[0] == {
"project_id": "awoooi",
"window_hours": 24,
"limit": 6,
}
@pytest.mark.asyncio
async def test_atomic_closure_rolls_back_when_bundle_readback_is_missing(
monkeypatch: pytest.MonkeyPatch,