Files
praxis/server/assist/lifecycle.py
T
Praxis CI 81d43666c7 feat(P01): complete assist core + guardrail phase — v0.1.11 tagged
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---
2026-08-04 21:19:20 +00:00

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"]