"""Hourly cleanup job: flip stale active L1WalkSessions to 'abandoned'. Sessions with status='active' and last_step_at older than 24h are considered abandoned (L1 closed the browser, customer hung up, etc.). Flipping them removes them from the "Resume in progress" widget while preserving the row for audit/reporting. Run via APScheduler interval job, max_instances=1 (Lesson 1). """ import logging from datetime import datetime, timedelta, timezone from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from app.models.l1_walk_session import L1WalkSession logger = logging.getLogger(__name__) async def flip_stale_sessions(db: AsyncSession) -> int: """Flip active sessions to 'abandoned' if last_step_at < now - 24h. Returns the number of sessions flipped. """ cutoff = datetime.now(timezone.utc) - timedelta(hours=24) stmt = ( update(L1WalkSession) .where(L1WalkSession.status == "active") .where(L1WalkSession.last_step_at < cutoff) .values(status="abandoned") ) result = await db.execute(stmt) await db.commit() return result.rowcount or 0 async def run_cleanup_job(session_factory) -> None: """APScheduler entry point. Uses the admin session factory (no RLS context).""" async with session_factory() as db: try: count = await flip_stale_sessions(db) if count > 0: logger.info( "l1_session_cleanup: flipped %d sessions to abandoned", count ) except Exception: logger.exception("l1_session_cleanup: error during run")