41f9427224
When a manifest mount target uses ~/{{WORKSPACE_NAME}}, the Dockerfile was
building a literal directory named {{WORKSPACE_NAME}} into the image and
creating a broken /workspace symlink. The runtime mount then created the
correct repo-named folder alongside the placeholder folder.
- Only create static mount target directories in the Dockerfile; skip any
target containing {{WORKSPACE_NAME}}
- Only create the /workspace compatibility symlink at image-build time when
the workspace name is known; otherwise let the entrypoint create it from
the WORKSPACE_NAME environment variable
- Update unit tests to cover both build-time workspace names and runtime
placeholders
Quality gates:
- pytest tests/unit: 211 passed
- ruff: clean on changed files
- mypy: clean on changed files
328 lines
12 KiB
Python
328 lines
12 KiB
Python
"""Background health monitor that polls container and tunnel health."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.database import SessionLocal
|
|
from src.models import HealthCheck
|
|
from src.models import ToolInstance
|
|
from src.services.shared.correlation import get_correlation_id
|
|
from src.services.docker import get_container_status
|
|
from src.services.shared.tunnel import check_tunnel_health
|
|
from src.services.instance.event_bus import InstanceEventBus, InstanceEventPayload
|
|
from src.services.shared.notification_service import notification_service
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class HealthSnapshot:
|
|
"""In-memory snapshot of an instance's health state."""
|
|
|
|
container_status: str | None = None
|
|
container_healthy: bool | None = None
|
|
tunnel_healthy: bool | None = None
|
|
exit_code: int | None = None
|
|
|
|
|
|
class HealthMonitor:
|
|
"""Polls container and tunnel health, publishing events on state changes."""
|
|
|
|
POLL_INTERVAL_SECONDS: float = 15.0
|
|
_MONITORED_STATUSES: set[str] = {"starting", "running", "unhealthy"}
|
|
|
|
def __init__(self, event_bus: InstanceEventBus) -> None:
|
|
self._event_bus = event_bus
|
|
self._task: asyncio.Task | None = None
|
|
self._last_known_state: dict[uuid.UUID, HealthSnapshot] = {}
|
|
|
|
def start(self) -> None:
|
|
"""Idempotent start of the background polling task."""
|
|
if self._task is not None and not self._task.done():
|
|
return
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
self._task = loop.create_task(self._poll_loop())
|
|
except RuntimeError:
|
|
pass
|
|
|
|
def stop(self) -> None:
|
|
"""Cancel the background task and clear state."""
|
|
if self._task is not None and not self._task.done():
|
|
self._task.cancel()
|
|
self._last_known_state.clear()
|
|
self._task = None
|
|
|
|
async def _poll_loop(self) -> None:
|
|
"""Main polling loop."""
|
|
while True:
|
|
try:
|
|
await asyncio.sleep(self.POLL_INTERVAL_SECONDS)
|
|
await self._run_check_cycle()
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception:
|
|
logger.exception("Health monitor poll loop error")
|
|
|
|
async def _run_check_cycle(self) -> None:
|
|
"""Check all monitored instances in one cycle."""
|
|
async with SessionLocal() as session:
|
|
result = await session.execute(
|
|
select(ToolInstance).where(
|
|
ToolInstance.status.in_(self._MONITORED_STATUSES)
|
|
)
|
|
)
|
|
instances = result.scalars().all()
|
|
|
|
for instance in instances:
|
|
async with SessionLocal() as session:
|
|
await self._check_instance(session, instance)
|
|
|
|
async def _check_instance(
|
|
self,
|
|
session: AsyncSession,
|
|
instance: ToolInstance,
|
|
) -> None:
|
|
"""Check a single instance and handle state transitions."""
|
|
# Skip instances that have never been assigned a container.
|
|
if not instance.container_id:
|
|
logger.debug(
|
|
"Skipping health check for instance %s: no container_id",
|
|
instance.id,
|
|
)
|
|
return
|
|
|
|
try:
|
|
container_info = get_container_status(instance.container_id)
|
|
except Exception:
|
|
logger.exception(
|
|
"Health check failed for instance %s",
|
|
instance.id,
|
|
extra={
|
|
"instance_id": str(instance.id),
|
|
"correlation_id": get_correlation_id(),
|
|
},
|
|
)
|
|
return
|
|
|
|
container_status = container_info["status"]
|
|
exit_code = container_info["exit_code"]
|
|
container_healthy = (
|
|
container_info["health"] == "healthy" if container_info["health"] else None
|
|
)
|
|
|
|
tunnel_healthy: bool | None = None
|
|
if instance.public_url and container_status == "running":
|
|
try:
|
|
tunnel_result = check_tunnel_health(instance.public_url)
|
|
tunnel_healthy = tunnel_result.get("healthy", False)
|
|
except Exception:
|
|
logger.exception(
|
|
"Tunnel health check failed for instance %s",
|
|
instance.id,
|
|
extra={
|
|
"instance_id": str(instance.id),
|
|
"correlation_id": get_correlation_id(),
|
|
},
|
|
)
|
|
tunnel_healthy = False
|
|
|
|
snapshot = HealthSnapshot(
|
|
container_status=container_status,
|
|
container_healthy=container_healthy,
|
|
tunnel_healthy=tunnel_healthy,
|
|
exit_code=exit_code,
|
|
)
|
|
|
|
previous = self._last_known_state.get(instance.id)
|
|
|
|
# Determine new status
|
|
new_status = self._derive_status(
|
|
snapshot,
|
|
previous,
|
|
instance.status,
|
|
)
|
|
|
|
# If first check or snapshot changed
|
|
if previous is None:
|
|
# Monitor restart / first time seeing this instance.
|
|
# Only act if the derived status is different from the DB status.
|
|
# This prevents duplicate notifications after monitor restarts.
|
|
if new_status == instance.status:
|
|
self._last_known_state[instance.id] = snapshot
|
|
return
|
|
elif self._snapshots_equal(previous, snapshot):
|
|
# Nothing changed since last poll — skip entirely.
|
|
return
|
|
|
|
await self._handle_state_change(
|
|
session, instance, previous, snapshot, new_status
|
|
)
|
|
self._last_known_state[instance.id] = snapshot
|
|
|
|
def _derive_status(
|
|
self,
|
|
snapshot: HealthSnapshot,
|
|
previous: HealthSnapshot | None,
|
|
current_status: str | None,
|
|
) -> str:
|
|
"""Derive instance status from health snapshot.
|
|
|
|
Treats missing containers as an error only when the container was
|
|
previously known to be running. This avoids false "container failed"
|
|
alerts for instances that are still starting or have no container yet.
|
|
"""
|
|
if snapshot.container_status == "not_found":
|
|
# Still starting — container may not exist yet.
|
|
if current_status == "starting":
|
|
return "starting"
|
|
# Container disappeared while it was supposed to be running.
|
|
if current_status == "running":
|
|
return "error"
|
|
# If we have previous memory and the container was running,
|
|
# mark as error (handles monitor restart edge case).
|
|
if previous is not None and previous.container_status == "running":
|
|
return "error"
|
|
# Fall back to current status to avoid spurious errors.
|
|
return current_status or "error"
|
|
|
|
if snapshot.container_status in ("exited", "dead"):
|
|
return "error"
|
|
|
|
if snapshot.container_status == "running":
|
|
if snapshot.tunnel_healthy is False:
|
|
return "unhealthy"
|
|
return "running"
|
|
|
|
# Transient states (created, restarting) — preserve current status
|
|
# instead of treating them as an error. The next poll will resolve.
|
|
if snapshot.container_status in ("created", "restarting"):
|
|
# A container that was already running and is now restarting has
|
|
# crashed (e.g. entrypoint failure / restart loop). Mark it failed
|
|
# so the dashboard does not keep showing it as running.
|
|
if current_status == "running":
|
|
return "error"
|
|
return current_status or "starting"
|
|
|
|
# Unknown/unexpected state (paused, etc.)
|
|
return "error"
|
|
|
|
def _snapshots_equal(self, a: HealthSnapshot, b: HealthSnapshot) -> bool:
|
|
"""Compare two snapshots for equality."""
|
|
return (
|
|
a.container_status == b.container_status
|
|
and a.container_healthy == b.container_healthy
|
|
and a.tunnel_healthy == b.tunnel_healthy
|
|
and a.exit_code == b.exit_code
|
|
)
|
|
|
|
async def _handle_state_change(
|
|
self,
|
|
session: AsyncSession,
|
|
instance: ToolInstance,
|
|
previous: HealthSnapshot | None,
|
|
snapshot: HealthSnapshot,
|
|
new_status: str,
|
|
) -> None:
|
|
"""Update DB, insert health check, and publish event."""
|
|
previous_status = instance.status
|
|
|
|
# Update instance status
|
|
instance.status = new_status
|
|
if new_status == "error":
|
|
instance.last_stopped_at = datetime.now(timezone.utc)
|
|
|
|
# Insert health check row
|
|
health_check = HealthCheck(
|
|
instance_id=instance.id,
|
|
container_status=snapshot.container_status,
|
|
container_healthy=snapshot.container_healthy,
|
|
tunnel_healthy=snapshot.tunnel_healthy,
|
|
exit_code=snapshot.exit_code,
|
|
probe_status=None,
|
|
probe_output=None,
|
|
)
|
|
session.add(health_check)
|
|
await session.commit()
|
|
|
|
# Build event payload
|
|
correlation_id = get_correlation_id()
|
|
metadata: dict = {"previous_status": previous_status}
|
|
if snapshot.exit_code is not None:
|
|
metadata["exit_code"] = snapshot.exit_code
|
|
metadata["error_type"] = "container"
|
|
if instance.public_url:
|
|
metadata["tunnel_url"] = instance.public_url
|
|
|
|
if new_status == "error":
|
|
event_type = "instance.error"
|
|
message = f"Container failed with status {snapshot.container_status}"
|
|
if snapshot.exit_code is not None:
|
|
message += f" (exit code: {snapshot.exit_code})"
|
|
else:
|
|
event_type = "instance.health_changed"
|
|
if new_status == "unhealthy":
|
|
message = "Container tunnel is unreachable"
|
|
else:
|
|
message = f"Container is now {new_status}"
|
|
|
|
payload: InstanceEventPayload = {
|
|
"event": event_type,
|
|
"instance_id": str(instance.id),
|
|
"status": new_status,
|
|
"message": message,
|
|
"metadata": metadata,
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"correlation_id": correlation_id,
|
|
}
|
|
|
|
await self._event_bus.publish(event_type, payload)
|
|
|
|
# Create notification for instance owner (fire-and-forget)
|
|
# Only send warnings and errors; skip "recovered" info notifications.
|
|
if new_status == "error":
|
|
# Skip duplicate error notifications if already in error state.
|
|
if previous_status == "error":
|
|
return
|
|
# Skip "not_found" errors for containers that were never running
|
|
# (e.g. still starting, or intentionally stopped/deleted).
|
|
if snapshot.container_status == "not_found" and (
|
|
previous is None or previous.container_status != "running"
|
|
):
|
|
return
|
|
category = "instance"
|
|
severity = "error"
|
|
title = "Container failed"
|
|
elif new_status == "unhealthy":
|
|
category = "health"
|
|
severity = "warning"
|
|
title = "Container unhealthy"
|
|
else:
|
|
# Running/recovered — do not notify
|
|
return
|
|
|
|
try:
|
|
await notification_service.create_notification(
|
|
session=session,
|
|
user_id=instance.owner_id,
|
|
category=category,
|
|
severity=severity,
|
|
title=title,
|
|
message=message,
|
|
source_type="tool_instances",
|
|
source_id=instance.id,
|
|
metadata=metadata,
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
"Failed to create notification for health event %s",
|
|
event_type,
|
|
extra={"correlation_id": correlation_id},
|
|
)
|