Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions engraphis/dashboard_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,24 @@ async def _dashboard_consolidation_loop(service: MemoryService) -> None:
logger.error("Dashboard consolidation loop error (%s)", type(exc).__name__)


async def _dashboard_heartbeat_loop() -> None:
"""Periodic heartbeat log to keep pm2 log capture active across rotations.

pm2-logrotate rotates logs at midnight; without periodic output the captured
stdout/stderr stream goes silent and the new log file stays 0 bytes. A lightweight
INFO heartbeat every 5 minutes ensures the log file receives fresh content.
"""
HEARTBEAT_INTERVAL = 300 # 5 minutes
while True:
try:
await asyncio.sleep(HEARTBEAT_INTERVAL)
logger.info("Dashboard heartbeat - service healthy")
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001 - heartbeat must not kill the server
pass


class _FreshStaticFiles(StaticFiles):
"""Revalidate local dashboard assets so a running UI cannot pin an old renderer.

Expand Down Expand Up @@ -384,6 +402,7 @@ def create_app() -> FastAPI:
@_contextlib.asynccontextmanager
async def _lifespan(app: FastAPI):
background_task = None
heartbeat_task = None
try: # one-line "update available" notice (background, fail-silent, opt-out)
import logging as _logging

Expand All @@ -397,6 +416,9 @@ async def _lifespan(app: FastAPI):
"Dashboard consolidation loop started (interval=%ds)",
settings.loop_interval,
)
# Always start heartbeat to keep pm2 log capture alive across rotations
heartbeat_task = asyncio.create_task(_dashboard_heartbeat_loop())
logger.info("Dashboard heartbeat loop started (interval=300s)")
try:
if _mcp_asgi is not None and _mcp_mgr is not None:
async with _mcp_mgr.run():
Expand All @@ -411,6 +433,12 @@ async def _lifespan(app: FastAPI):
await background_task
except asyncio.CancelledError:
pass
if heartbeat_task is not None:
heartbeat_task.cancel()
try:
await heartbeat_task
except asyncio.CancelledError:
pass
finally:
await asyncio.to_thread(v2_api.release_service, svc)

Expand Down