diff --git a/app.py b/app.py index 4a8a12df8..90543fba8 100644 --- a/app.py +++ b/app.py @@ -595,6 +595,14 @@ webhook_manager = WebhookManager(api_key_manager=api_key_manager) auth_router = setup_auth_routes(auth_manager) app.include_router(auth_router) + +@app.post("/api/activity/heartbeat") +async def activity_heartbeat(): + from src.interactive_gate import mark_browser_activity + await mark_browser_activity() + return {"ok": True} + + # Uploads from routes.upload_routes import setup_upload_routes upload_router, upload_cleanup_func = setup_upload_routes(upload_handler) diff --git a/src/interactive_gate.py b/src/interactive_gate.py index cdb3892cf..7cfd01154 100644 --- a/src/interactive_gate.py +++ b/src/interactive_gate.py @@ -15,6 +15,7 @@ import time _ACTIVE_REQUESTS = 0 _LAST_ACTIVITY = 0.0 +_LAST_BROWSER_ACTIVITY = 0.0 _COND: asyncio.Condition | None = None @@ -37,6 +38,14 @@ def _max_wait_seconds() -> float: return 0.0 +def _browser_active_seconds() -> float: + """How long a visible Odysseus browser heartbeat blocks background tasks.""" + try: + return max(0.0, float(os.getenv("BACKGROUND_TASK_BROWSER_ACTIVE_SECONDS", "45"))) + except Exception: + return 45.0 + + def _condition() -> asyncio.Condition: global _COND if _COND is None: @@ -45,6 +54,7 @@ def _condition() -> asyncio.Condition: _PASSIVE_EXACT_PATHS = { + "/api/activity/heartbeat", "/api/tasks/notifications", "/api/research/active", "/api/email/urgency-state", @@ -69,6 +79,24 @@ def should_track_interactive_request(path: str, method: str = "GET") -> bool: return True +async def mark_browser_activity() -> None: + """Record that an authenticated browser tab is visibly using Odysseus.""" + global _LAST_BROWSER_ACTIVITY + if not _enabled(): + return + cond = _condition() + async with cond: + _LAST_BROWSER_ACTIVITY = time.monotonic() + cond.notify_all() + + +def _has_recent_browser_activity(now: float | None = None) -> bool: + ttl = _browser_active_seconds() + if ttl <= 0 or _LAST_BROWSER_ACTIVITY <= 0: + return False + return ((now if now is not None else time.monotonic()) - _LAST_BROWSER_ACTIVITY) < ttl + + def _has_active_chat_stream() -> bool: """Best-effort check for foreground model work that outlives HTTP requests. @@ -133,11 +161,12 @@ async def wait_for_interactive_quiet(label: str = "") -> bool: now = time.monotonic() quiet_remaining = quiet - (now - _LAST_ACTIVITY) active_stream = _has_active_chat_stream() - if _ACTIVE_REQUESTS <= 0 and quiet_remaining <= 0 and not active_stream: + browser_active = _has_recent_browser_activity(now) + if _ACTIVE_REQUESTS <= 0 and quiet_remaining <= 0 and not active_stream and not browser_active: return waited waited = True - timeout = 0.25 if (_ACTIVE_REQUESTS > 0 or active_stream) else min(max(quiet_remaining, 0.05), 0.5) + timeout = 0.25 if (_ACTIVE_REQUESTS > 0 or active_stream or browser_active) else min(max(quiet_remaining, 0.05), 0.5) if deadline is not None: remaining = deadline - now if remaining <= 0: diff --git a/static/app.js b/static/app.js index a32fdf77c..6d11106b4 100644 --- a/static/app.js +++ b/static/app.js @@ -53,6 +53,40 @@ window.uiModule = uiModule; window.adminModule = adminModule; window.cookbookModule = cookbookModule; +function initForegroundActivityHeartbeat() { + let lastSent = 0; + const minGapMs = 12000; + const send = (force = false) => { + if (document.visibilityState === 'hidden') return; + const now = Date.now(); + if (!force && now - lastSent < minGapMs) return; + lastSent = now; + try { + if (navigator.sendBeacon) { + const body = new Blob(['{}'], { type: 'application/json' }); + if (navigator.sendBeacon('/api/activity/heartbeat', body)) return; + } + } catch (_) {} + fetch('/api/activity/heartbeat', { + method: 'POST', + credentials: 'same-origin', + keepalive: true, + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }).catch(() => {}); + }; + send(true); + window.addEventListener('focus', () => send(true)); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState !== 'hidden') send(true); + }); + ['pointerdown', 'keydown', 'touchstart', 'scroll'].forEach(type => { + window.addEventListener(type, () => send(false), { passive: true, capture: true }); + }); + setInterval(() => send(false), 15000); +} +initForegroundActivityHeartbeat(); + // Redirect to login on 401 from any fetch const _origFetch = window.fetch; window.fetch = async function(...args) {