"""ShiftLifecycleManager — 8h auto-end for assist shifts (D-069, TASK-02-02). R-ASSIST-11 mitigation: auto-end after 8h closes the shift cleanly, fires the aggregation hook, and releases the WebRTC connection (SLICE-06 closes the connection on shift-end). The monitor runs every 5 minutes (the 8h boundary is not latency-critical). PRAXIS_ASSIST_MAX_SHIFT_HOURS env var (default 8 per D-069). """ from __future__ import annotations import asyncio import datetime as _dt import logging import os from typing import Any from db.store import PraxisStore log = logging.getLogger(__name__) _DEFAULT_MAX_SHIFT_HOURS = 8 _MONITOR_INTERVAL_S = 300 # 5 minutes class ShiftLifecycleManager: """Manages the 8h auto-end for assist shifts (D-069).""" def __init__( self, store: PraxisStore, max_shift_hours: int | None = None, pg_store: Any = None, ) -> None: self.store = store if max_shift_hours is None: env_val = os.environ.get("PRAXIS_ASSIST_MAX_SHIFT_HOURS", "").strip() max_shift_hours = int(env_val) if env_val else _DEFAULT_MAX_SHIFT_HOURS self.max_shift_hours = max_shift_hours self.pg_store = pg_store self._monitor_task: asyncio.Task | None = None async def check_auto_end(self) -> list[str]: """Find active assist shifts older than max_shift_hours; auto-end them. Returns the list of auto-ended shift ids. Outcome is 'auto_ended'. """ cutoff = _dt.datetime.now(_dt.timezone.utc) - _dt.timedelta( hours=self.max_shift_hours ) active = await self.store.list_active_assist_sessions() ended: list[str] = [] for row in active: started_at_str = row.get("started_at") if not started_at_str: continue try: # SQLite datetime('now') format: "YYYY-MM-DD HH:MM:SS" (UTC). started = _dt.datetime.fromisoformat(started_at_str.replace(" ", "T")) if started.tzinfo is None: started = started.replace(tzinfo=_dt.timezone.utc) except ValueError: continue if started < cutoff: shift_id = row["id"] await self._auto_end_shift(row, outcome="auto_ended") ended.append(shift_id) log.info( "auto-ended assist shift %s (started %s, exceeded %dh)", shift_id, started_at_str, self.max_shift_hours, ) return ended async def _auto_end_shift(self, row: dict, outcome: str) -> None: """End an auto-expired shift: update the session row + fire the hook.""" shift_id = row["id"] await self.store.end_session_assist(shift_id, outcome, 0, 0) if self.pg_store is not None: try: from server.cohort.hook import on_session_end session_outcome = { "learner_ref": row.get("learner_id", "unknown"), "path": "customer_service", "scenario_id": row.get("scenario_id", "assist:unknown"), "outcome": outcome, "session_type": "assist", "rubric_scores": [], "failure_mode": None, "branch_path": [], "assist_turn_count": 0, "guardrail_blocks": 0, "timestamp": _dt.datetime.now(_dt.timezone.utc).isoformat(), } await on_session_end(self.pg_store, session_outcome) except Exception: log.exception("auto-end aggregation hook failed for shift %s", shift_id) async def start_monitor(self) -> None: """Start the 5-minute auto-end monitor (asyncio task).""" if self._monitor_task is not None: return self._monitor_task = asyncio.create_task(self._monitor_loop()) log.info( "ShiftLifecycleManager monitor started (interval=%ds, max_shift=%dh)", _MONITOR_INTERVAL_S, self.max_shift_hours, ) async def stop_monitor(self) -> None: """Cancel the monitor task.""" if self._monitor_task is not None: self._monitor_task.cancel() try: await self._monitor_task except asyncio.CancelledError: pass self._monitor_task = None log.info("ShiftLifecycleManager monitor stopped") async def _monitor_loop(self) -> None: """Run check_auto_end() every 5 minutes until cancelled.""" while True: try: await self.check_auto_end() except Exception: log.exception("ShiftLifecycleManager check_auto_end failed") await asyncio.sleep(_MONITOR_INTERVAL_S) __all__ = ["ShiftLifecycleManager"]