- Implemented HubSnapshot and central HubStateStore for normalized in-memory state caching (<0.05ms) - Refactored AccountsView and RoutingView with reusable AccountCardWidget and RoutingRoleWidget to eliminate widget recreation - Implemented central HermesRefreshScheduler with 5s tick, concurrency throttling, dedup, and spread initial delays - Added typed EventBus with thread-safe UI main loop dispatching via root.after - Implemented dynamic ModelRegistry with capability-based role requirements and multi-dimensional scoring - Integrated Antigravity separate quota buckets (Claude vs Gemini) and same-account model fallback - Enhanced SessionAffinityTracker with TTL expiration and LRU capacity bounds - Eliminated long subprocess holding of _CM_LOCK and ensured Windows credential restoration in finally block - Added FastAPI REST contracts in gui_server.py as foundation for future Tauri frontend - Verified 100% pass across all 91 pytest tests and 7/7 release gate criteria
121 lines
4.1 KiB
Python
121 lines
4.1 KiB
Python
"""Session affinity and lease management for Hermes multi-provider router."""
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
|
|
@dataclass
|
|
class SessionAffinityRecord:
|
|
session_id: str
|
|
role: str
|
|
profile_id: str
|
|
model: Optional[str] = None
|
|
created_at: float = 0.0
|
|
updated_at: float = 0.0
|
|
|
|
|
|
class SessionAffinityTracker:
|
|
"""Thread-safe tracker maintaining session affinity across conversation turns with TTL & LRU bounds."""
|
|
|
|
def __init__(self, ttl_seconds: int = 1800, max_entries: int = 1000) -> None:
|
|
self._lock = threading.RLock()
|
|
self.ttl_seconds = ttl_seconds
|
|
self.max_entries = max_entries
|
|
self._sessions: dict[str, SessionAffinityRecord] = {}
|
|
|
|
def get_affinity(self, session_id: Optional[str]) -> Optional[SessionAffinityRecord]:
|
|
if not session_id:
|
|
return None
|
|
with self._lock:
|
|
rec = self._sessions.get(session_id)
|
|
if not rec:
|
|
return None
|
|
now = time.time()
|
|
if self.ttl_seconds > 0 and (now - rec.updated_at) > self.ttl_seconds:
|
|
# Expired -> prune and return None
|
|
self._sessions.pop(session_id, None)
|
|
return None
|
|
return rec
|
|
|
|
def set_affinity(
|
|
self,
|
|
session_id: Optional[str],
|
|
role: str,
|
|
profile_id: str,
|
|
model: Optional[str] = None,
|
|
) -> None:
|
|
if not session_id:
|
|
return
|
|
with self._lock:
|
|
now = time.time()
|
|
# Enforce max capacity & pruning
|
|
if len(self._sessions) >= self.max_entries and session_id not in self._sessions:
|
|
self.prune_expired()
|
|
if len(self._sessions) >= self.max_entries:
|
|
# Evict oldest entry (LRU)
|
|
oldest_key = min(self._sessions.keys(), key=lambda k: self._sessions[k].updated_at)
|
|
self._sessions.pop(oldest_key, None)
|
|
|
|
if session_id in self._sessions:
|
|
rec = self._sessions[session_id]
|
|
rec.role = role
|
|
rec.profile_id = profile_id
|
|
rec.model = model
|
|
rec.updated_at = now
|
|
else:
|
|
self._sessions[session_id] = SessionAffinityRecord(
|
|
session_id=session_id,
|
|
role=role,
|
|
profile_id=profile_id,
|
|
model=model,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
|
|
def prune_expired(self) -> int:
|
|
"""Remove all expired affinity records. Returns count of pruned records."""
|
|
with self._lock:
|
|
if self.ttl_seconds <= 0:
|
|
return 0
|
|
now = time.time()
|
|
expired_keys = [k for k, v in self._sessions.items() if (now - v.updated_at) > self.ttl_seconds]
|
|
for k in expired_keys:
|
|
self._sessions.pop(k, None)
|
|
return len(expired_keys)
|
|
|
|
def clear_session(self, session_id: str) -> None:
|
|
with self._lock:
|
|
self._sessions.pop(session_id, None)
|
|
|
|
def clear_all(self) -> None:
|
|
with self._lock:
|
|
self._sessions.clear()
|
|
|
|
|
|
class LeaseManager:
|
|
"""Manages concurrent leases per profile to prevent process saturation."""
|
|
|
|
def __init__(self) -> None:
|
|
self._lock = threading.RLock()
|
|
self._active_leases: dict[str, int] = {}
|
|
|
|
def acquire(self, profile_id: str, max_concurrency: int = 1) -> bool:
|
|
with self._lock:
|
|
current = self._active_leases.get(profile_id, 0)
|
|
if current >= max_concurrency:
|
|
return False
|
|
self._active_leases[profile_id] = current + 1
|
|
return True
|
|
|
|
def release(self, profile_id: str) -> None:
|
|
with self._lock:
|
|
current = self._active_leases.get(profile_id, 0)
|
|
if current > 0:
|
|
self._active_leases[profile_id] = current - 1
|
|
|
|
def active_count(self, profile_id: str) -> int:
|
|
with self._lock:
|
|
return self._active_leases.get(profile_id, 0)
|