From b2c52abf06555cd5ae4e5b359b96de96f0d1ae88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A8=B8=EB=8B=88=ED=8E=98=EB=8B=88?= Date: Fri, 20 Mar 2026 18:46:24 +0900 Subject: [PATCH] feat: integrate Recovery, CostGuard, ContainerCleaner in lifespan - webapp.py lifespan now initializes json_logging, recovery, cost_guard, task_history - Dispatcher receives cost_guard and task_history dependencies - ContainerCleaner starts if Docker is available - Added /health/costs endpoint for API cost monitoring - Added tests/test_smoke.py with 2 tests for basic health and costs endpoint - All existing health tests still pass (8 tests) --- agent/webapp.py | 43 ++++++++++++++++++++++++++++++++++++++++++- tests/test_smoke.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 tests/test_smoke.py diff --git a/agent/webapp.py b/agent/webapp.py index dd96ef1..a8f3b91 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -24,14 +24,43 @@ async def lifespan(app: FastAPI): from agent.message_store import get_message_store from agent.dispatcher import Dispatcher from agent.integrations.discord_handler import DiscordHandler + from agent.json_logging import setup_logging + from agent.recovery import recover_on_startup, ContainerCleaner + from agent.cost_guard import get_cost_guard + from agent.task_history import get_task_history + + # 구조화 로깅 설정 + setup_logging(log_format=os.environ.get("LOG_FORMAT", "json")) task_queue = await get_task_queue() message_store = await get_message_store() - dispatcher = Dispatcher(task_queue=task_queue) + # 서버 시작 시 복구 + await recover_on_startup(task_queue) + + # CostGuard + TaskHistory 초기화 + cost_guard = await get_cost_guard() + task_history = await get_task_history() + + # Dispatcher에 CostGuard + TaskHistory 주입 + dispatcher = Dispatcher(task_queue=task_queue, cost_guard=cost_guard, task_history=task_history) await dispatcher.start() app.state.dispatcher = dispatcher + # ContainerCleaner 시작 + container_cleaner = None + try: + import docker + docker_client = docker.from_env() + sandbox_timeout = int(os.environ.get("SANDBOX_TIMEOUT", "600")) + container_cleaner = ContainerCleaner( + docker_client=docker_client, + max_age_seconds=sandbox_timeout * 2, + ) + await container_cleaner.start() + except Exception: + logger.debug("Docker not available, container cleanup disabled") + discord_token = os.environ.get("DISCORD_TOKEN", "") discord_handler = None if discord_token: @@ -43,8 +72,12 @@ async def lifespan(app: FastAPI): yield await dispatcher.stop() + if container_cleaner: + await container_cleaner.stop() if discord_handler: await discord_handler.close() + await cost_guard.close() + await task_history.close() await task_queue.close() await message_store.close() logger.info("Application shutdown complete") @@ -170,6 +203,14 @@ async def health_queue(): } +@app.get("/health/costs") +async def health_costs(): + """API 비용 현황을 반환한다.""" + from agent.cost_guard import get_cost_guard + guard = await get_cost_guard() + return await guard.get_daily_summary() + + @app.post("/webhooks/gitea") @limiter.limit("10/minute") async def gitea_webhook(request: Request): diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..c4442e5 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,44 @@ +import pytest +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx + + +@asynccontextmanager +async def mock_lifespan(app): + yield + + +@pytest.mark.asyncio +async def test_smoke_health(): + from agent.webapp import app + app.router.lifespan_context = mock_lifespan + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + +@pytest.mark.asyncio +async def test_smoke_health_costs(): + from agent.webapp import app + app.router.lifespan_context = mock_lifespan + + mock_guard = MagicMock() + mock_guard.get_daily_summary = AsyncMock(return_value={ + "total_cost_usd": 1.5, + "daily_limit_usd": 10.0, + "remaining_usd": 8.5, + "record_count": 3, + "total_tokens_input": 50000, + "total_tokens_output": 20000, + }) + + with patch("agent.cost_guard.get_cost_guard", new_callable=AsyncMock, return_value=mock_guard): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + resp = await client.get("/health/costs") + assert resp.status_code == 200 + data = resp.json() + assert data["total_cost_usd"] == 1.5 + assert data["daily_limit_usd"] == 10.0