81d43666c7
Phase 1 (Assist Core + Guardrail) complete. 8 slices, 4 waves, 24 tasks. 12 REQs covered (3 ASSIST + 3 NFR + 6 IDEATE). 92 new tests (409 total). G-049 + G-067 MUSTs resolved. Verify: APPROVE_WITH_NOTES, 5 P1+ flagged. Live Assist voice loop: shift-bounded sessions, context-binding, 3-layer guardrail (prompt + regex filter + audit log), tap-to-talk client control, warm WebRTC, reconnect logic, incremental audit write, PII policy, consent disclosure, mode-conflict enforcement. ---ci--- project: praxis phase: 1 milestone: v0.5 status: complete requirements: covered: [REQ-ASSIST-01, REQ-ASSIST-02, REQ-ASSIST-03, REQ-NFR-ASSIST-02, REQ-NFR-ASSIST-03, REQ-NFR-ASSIST-04, REQ-IDEATE-01, REQ-IDEATE-02, REQ-IDEATE-03, REQ-IDEATE-05, REQ-IDEATE-08, REQ-IDEATE-09] partial: [] ---/ci---
131 lines
4.8 KiB
Python
131 lines
4.8 KiB
Python
"""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"] |