@@ -242,6 +242,24 @@ async def _dashboard_consolidation_loop(service: MemoryService) -> None:
242242 logger .error ("Dashboard consolidation loop error (%s)" , type (exc ).__name__ )
243243
244244
245+ async def _dashboard_heartbeat_loop () -> None :
246+ """Periodic heartbeat log to keep pm2 log capture active across rotations.
247+
248+ pm2-logrotate rotates logs at midnight; without periodic output the captured
249+ stdout/stderr stream goes silent and the new log file stays 0 bytes. A lightweight
250+ INFO heartbeat every 5 minutes ensures the log file receives fresh content.
251+ """
252+ HEARTBEAT_INTERVAL = 300 # 5 minutes
253+ while True :
254+ try :
255+ await asyncio .sleep (HEARTBEAT_INTERVAL )
256+ logger .info ("Dashboard heartbeat - service healthy" )
257+ except asyncio .CancelledError :
258+ raise
259+ except Exception : # noqa: BLE001 - heartbeat must not kill the server
260+ pass
261+
262+
245263class _FreshStaticFiles (StaticFiles ):
246264 """Revalidate local dashboard assets so a running UI cannot pin an old renderer.
247265
@@ -384,6 +402,7 @@ def create_app() -> FastAPI:
384402 @_contextlib .asynccontextmanager
385403 async def _lifespan (app : FastAPI ):
386404 background_task = None
405+ heartbeat_task = None
387406 try : # one-line "update available" notice (background, fail-silent, opt-out)
388407 import logging as _logging
389408
@@ -397,6 +416,9 @@ async def _lifespan(app: FastAPI):
397416 "Dashboard consolidation loop started (interval=%ds)" ,
398417 settings .loop_interval ,
399418 )
419+ # Always start heartbeat to keep pm2 log capture alive across rotations
420+ heartbeat_task = asyncio .create_task (_dashboard_heartbeat_loop ())
421+ logger .info ("Dashboard heartbeat loop started (interval=300s)" )
400422 try :
401423 if _mcp_asgi is not None and _mcp_mgr is not None :
402424 async with _mcp_mgr .run ():
@@ -411,6 +433,12 @@ async def _lifespan(app: FastAPI):
411433 await background_task
412434 except asyncio .CancelledError :
413435 pass
436+ if heartbeat_task is not None :
437+ heartbeat_task .cancel ()
438+ try :
439+ await heartbeat_task
440+ except asyncio .CancelledError :
441+ pass
414442 finally :
415443 await asyncio .to_thread (v2_api .release_service , svc )
416444
0 commit comments