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)
This commit is contained in:
머니페니 2026-03-20 18:46:24 +09:00
parent c0cb4b7499
commit b2c52abf06
2 changed files with 86 additions and 1 deletions

View File

@ -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):

44
tests/test_smoke.py Normal file
View File

@ -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