"""Deployment-wide roundtable room leases and background-job occupancy. A room is one live roundtable execution, not one saved draft, not one account, and not one seat. Different pages running the same draft have distinct hidden session ids; every seat and step of one execution shares one id. The active set is the union of every user's: - a durable foreground/session lease renewed by the browser or a run entry - live ``extra_keys`` supplied by the Gateway as a reconciliation fallback only when no durable lease/job already represents that persisted scope - a background job that is executing or waiting for the user's Step-2 clarification (``queued`` / ``running`` / ``awaiting_input`` / ``cancel_requested``) Historical jobs older than the hard room lifetime and merely viewing chat history are not rooms. There is no per-user pool. Admission and lease creation happen while holding one database-backed gate row. Updating that row serializes contenders on SQLite, MySQL and PostgreSQL, so four simultaneous requests cannot all observe the same stale count. """ from __future__ import annotations import asyncio import hashlib from collections.abc import Mapping from datetime import UTC, datetime, timedelta from typing import Any from sqlalchemy import delete, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow from deerflow.persistence.roundtable_jobs.sql import compute_active_dedupe_key from deerflow.persistence.roundtable_rooms.model import RoundtableRoomLeaseRow DEFAULT_LEASE_SECONDS = 5 * 60 MIN_LEASE_SECONDS = 60 MAX_LEASE_SECONDS = 60 * 60 JOB_LIVE_SECONDS = 15 * 60 OCCUPYING_JOB_STATUSES = ("queued", "running", "awaiting_input", "cancel_requested") # A background job keeps running while Step 3 generates its artifacts, but the # concurrency cap is intentionally scoped to Step 2. ``consensus`` is written # before Step 3 begins, which releases the slot without waiting for reporting. NON_OCCUPYING_JOB_PHASES = frozenset({ "consensus", "summary_writing", "report_drawing", "report_done", }) GATE_ROW_KEY = "__roundtable_room_gate__" def compute_room_session_key(room_session_id: str | None) -> str | None: """Stable key for one live roundtable execution (not one saved draft).""" clean = (room_session_id or "").strip() if not clean: return None return hashlib.sha256(f"roundtable-session:{clean}".encode()).hexdigest() def _aware(value: datetime | None) -> datetime | None: if value is None: return None if value.tzinfo is None: return value.replace(tzinfo=UTC) return value def _job_is_live( lease_until: datetime | None, updated_at: datetime | None, now: datetime, live_after: datetime, ) -> bool: until = _aware(lease_until) if until is not None and until > now: return True updated = _aware(updated_at) return updated is not None and updated >= live_after def normalize_lease_seconds(value: int | None) -> int: """Clamp a foreground room lease to the supported 1–60 minute window.""" try: seconds = int(value) if value is not None else DEFAULT_LEASE_SECONDS except (TypeError, ValueError): seconds = DEFAULT_LEASE_SECONDS return max(MIN_LEASE_SECONDS, min(MAX_LEASE_SECONDS, seconds)) def _hard_deadline_reached( created_at: datetime | None, now: datetime, max_hold_minutes: int, ) -> bool: created = _aware(created_at) return created is not None and created + timedelta(minutes=max_hold_minutes) <= now class RoundtableRoomRepository: def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: self._sf = session_factory self._lock = asyncio.Lock() async def admit( self, *, draft_id: str | None, user_id: str | None, task_id: str | None, room_session_id: str | None = None, enabled: bool, max_rooms: int, max_hold_minutes: int = 30, lease_seconds: int = DEFAULT_LEASE_SECONDS, extra_keys: set[str] | Mapping[str, str | None] | None = None, ) -> dict[str, Any]: """Atomically reserve/renew a room, or reject when the pool is full.""" key = compute_room_session_key(room_session_id) or compute_active_dedupe_key( draft_id, user_id, task_id ) return await self.admit_room_key( room_key=key, draft_id=draft_id, user_id=user_id, task_id=task_id, holder_user_id=user_id, enabled=enabled, max_rooms=max_rooms, max_hold_minutes=max_hold_minutes, lease_seconds=lease_seconds, extra_keys=extra_keys, ) async def admit_room_key( self, *, room_key: str | None, draft_id: str | None, user_id: str | None, task_id: str | None, holder_user_id: str | None, enabled: bool, max_rooms: int, max_hold_minutes: int = 30, lease_seconds: int = DEFAULT_LEASE_SECONDS, extra_keys: set[str] | Mapping[str, str | None] | None = None, ) -> dict[str, Any]: """Atomically admit a pre-resolved room key. ``room_key`` is used by ``/run/stream`` when it resolves the room from thread metadata. ``user_id`` remains the room owner while ``holder_user_id`` is the account currently renewing a shared room. """ async with self._lock: await self._ensure_gate_row() now = datetime.now(UTC) async with self._sf() as session: # This no-op-ish write is intentional: it is the portable DB # mutex protecting the subsequent count + lease upsert. await session.execute( update(RoundtableRoomLeaseRow) .where(RoundtableRoomLeaseRow.room_key == GATE_ROW_KEY) .values(updated_at=now) ) keys, durable_scopes, hard_expired_keys = await self._active_snapshot( session, now, max_hold_minutes=max_hold_minutes, ) self._merge_extra_keys(keys, durable_scopes, extra_keys) if room_key is None: await session.commit() blocked = bool(enabled) return self._decision( ok=not blocked, enabled=enabled, max_rooms=max_rooms, active_count=len(keys), renewed=False, code="DRAFT_REQUIRED" if blocked else "", message="" if not blocked else "进入第二步需要先保存会商草稿", ) if room_key in hard_expired_keys: await session.commit() return self._decision( ok=False, enabled=enabled, max_rooms=max_rooms, active_count=len(keys), renewed=False, code="ROOM_MAX_HOLD_EXCEEDED", message="本次会商已达到最长占用时间,房间已自动释放。请返回第一步后重新发起。", ) owns = room_key in keys if enabled and not owns and len(keys) >= max_rooms: await session.commit() return self._decision( ok=False, enabled=enabled, max_rooms=max_rooms, active_count=len(keys), renewed=False, code="ROUNDTABLE_ROOM_FULL", message="当前会商房间暂时已满,请稍候片刻后再试。", ) await self._upsert_lease( session, room_key=room_key, draft_id=draft_id, user_id=user_id, task_id=task_id, holder_user_id=holder_user_id, now=now, lease_seconds=lease_seconds, ) await session.commit() return self._decision( ok=True, enabled=enabled, max_rooms=max_rooms, active_count=len(keys) if owns else len(keys) + 1, renewed=owns, code="", message="", ) async def release( self, *, draft_id: str | None, user_id: str | None, task_id: str | None, room_session_id: str | None = None, ) -> bool: """Drop a leftover foreground lease row if one still exists.""" key = compute_room_session_key(room_session_id) or compute_active_dedupe_key( draft_id, user_id, task_id ) if not key: return False async with self._lock: await self._ensure_gate_row() async with self._sf() as session: await session.execute( update(RoundtableRoomLeaseRow) .where(RoundtableRoomLeaseRow.room_key == GATE_ROW_KEY) .values(updated_at=datetime.now(UTC)) ) row = await session.get(RoundtableRoomLeaseRow, key) if row is None or (user_id and row.holder_user_id not in (None, user_id)): await session.commit() return False await session.delete(row) await session.commit() return True async def started_at( self, *, room_session_id: str | None, holder_user_id: str | None, ) -> datetime | None: """Return the original admission time for a foreground-to-job handoff.""" key = compute_room_session_key(room_session_id) if key is None: return None async with self._sf() as session: row = await session.get(RoundtableRoomLeaseRow, key) if row is None or ( holder_user_id and row.holder_user_id not in (None, holder_user_id) ): return None return _aware(row.created_at) async def renew( self, *, draft_id: str | None, user_id: str | None, task_id: str | None, room_session_id: str | None, enabled: bool, max_rooms: int, max_hold_minutes: int = 30, lease_seconds: int = DEFAULT_LEASE_SECONDS, extra_keys: set[str] | Mapping[str, str | None] | None = None, ) -> dict[str, Any]: """Renew one live foreground lease without ever creating it. Heartbeats are deliberately weaker than admission. A delayed browser callback must not resurrect a room that was released after a pause, network disconnect or completed Step 2. """ key = compute_room_session_key(room_session_id) if key is None: return self._decision( ok=not enabled, enabled=enabled, max_rooms=max_rooms, active_count=0, renewed=False, code="ROOM_SESSION_REQUIRED" if enabled else "", message="会商会话标识缺失,请刷新页面后重试。" if enabled else "", ) return await self.renew_room_key( room_key=key, holder_user_id=user_id, enabled=enabled, max_rooms=max_rooms, max_hold_minutes=max_hold_minutes, lease_seconds=lease_seconds, extra_keys=extra_keys, ) async def renew_room_key( self, *, room_key: str, holder_user_id: str | None, enabled: bool, max_rooms: int, max_hold_minutes: int = 30, lease_seconds: int = DEFAULT_LEASE_SECONDS, extra_keys: set[str] | Mapping[str, str | None] | None = None, ) -> dict[str, Any]: """Renew a resolved persisted key without creating a missing lease.""" async with self._lock: await self._ensure_gate_row() now = datetime.now(UTC) async with self._sf() as session: await session.execute( update(RoundtableRoomLeaseRow) .where(RoundtableRoomLeaseRow.room_key == GATE_ROW_KEY) .values(updated_at=now) ) keys, durable_scopes, hard_expired_keys = await self._active_snapshot( session, now, max_hold_minutes=max_hold_minutes, ) self._merge_extra_keys(keys, durable_scopes, extra_keys) row = await session.get(RoundtableRoomLeaseRow, room_key) if row is not None and room_key in hard_expired_keys: await session.commit() return self._decision( ok=False, enabled=enabled, max_rooms=max_rooms, active_count=len(keys), renewed=False, code="ROOM_MAX_HOLD_EXCEEDED", message="本次会商已达到最长占用时间,房间已自动释放。请返回第一步后重新发起。", ) owns = row is not None and room_key in keys and ( not holder_user_id or row.holder_user_id in (None, holder_user_id) ) if not owns: await session.commit() return self._decision( ok=not enabled, enabled=enabled, max_rooms=max_rooms, active_count=len(keys), renewed=False, code="ROOM_LEASE_EXPIRED" if enabled else "", message="会商连接已断开,请重新恢复研讨。" if enabled else "", ) row.lease_until = now + timedelta(seconds=normalize_lease_seconds(lease_seconds)) row.updated_at = now await session.commit() return self._decision( ok=True, enabled=enabled, max_rooms=max_rooms, active_count=len(keys), renewed=True, code="", message="", ) async def summary( self, extra_keys: set[str] | Mapping[str, str | None] | None = None, *, max_hold_minutes: int = 30, ) -> int: """Return the number of distinct occupied rooms across every user.""" async with self._lock: await self._ensure_gate_row() now = datetime.now(UTC) async with self._sf() as session: keys, durable_scopes, _hard_expired_keys = await self._active_snapshot( session, now, max_hold_minutes=max_hold_minutes, ) self._merge_extra_keys(keys, durable_scopes, extra_keys) await session.commit() return len(keys) @staticmethod def _merge_extra_keys( keys: set[str], durable_scopes: set[str], extra_keys: set[str] | Mapping[str, str | None] | None, ) -> None: """Add run-manager fallbacks without double-counting durable occupancy. The map value is the legacy draft/user-or-task scope. If a live page lease or background job already owns that scope, its stamped run key is merely evidence for the same room and must not become another room. """ if not extra_keys: return items = extra_keys.items() if isinstance(extra_keys, Mapping) else ((key, None) for key in extra_keys) for key, scope_key in items: if not key or (scope_key and scope_key in durable_scopes): continue keys.add(key) async def _active_snapshot( self, session: AsyncSession, now: datetime, *, max_hold_minutes: int, ) -> tuple[set[str], set[str], set[str]]: lease_rows = ( await session.execute( select(RoundtableRoomLeaseRow).where( RoundtableRoomLeaseRow.room_key != GATE_ROW_KEY ) ) ).scalars().all() expired: list[str] = [] keys: set[str] = set() durable_scopes: set[str] = set() hard_expired_keys: set[str] = set() for row in lease_rows: until = _aware(row.lease_until) if until is None or until <= now: expired.append(row.room_key) continue scope_key = compute_active_dedupe_key(row.draft_id, row.user_id, row.task_id) if _hard_deadline_reached(row.created_at, now, max_hold_minutes): # Keep the still-live row briefly as a non-occupying tombstone. # Its scope suppresses a lagging RunManager fallback until the # browser observes the rejected heartbeat and cancels the run. hard_expired_keys.add(row.room_key) if scope_key: durable_scopes.add(scope_key) continue keys.add(row.room_key) if scope_key: durable_scopes.add(scope_key) if expired: await session.execute( delete(RoundtableRoomLeaseRow).where( RoundtableRoomLeaseRow.room_key.in_(expired) ) ) live_after = now - timedelta(seconds=JOB_LIVE_SECONDS) job_rows = ( await session.execute( select( RoundtableJobRow.id, RoundtableJobRow.draft_id, RoundtableJobRow.user_id, RoundtableJobRow.task_id, RoundtableJobRow.status, RoundtableJobRow.phase, RoundtableJobRow.lease_until, RoundtableJobRow.updated_at, RoundtableJobRow.created_at, ).where(RoundtableJobRow.status.in_(OCCUPYING_JOB_STATUSES)) ) ).all() for job_id, draft_id, user_id, task_id, status, phase, lease_until, updated_at, created_at in job_rows: if phase in NON_OCCUPYING_JOB_PHASES: continue if _hard_deadline_reached(created_at, now, max_hold_minutes): # The worker cancellation watcher will finalize this state. # Marking the intent here also covers queued jobs and workers # that stopped renewing but have not yet been reaped. await session.execute( update(RoundtableJobRow) .where( RoundtableJobRow.id == job_id, RoundtableJobRow.status.in_(["queued", "running", "awaiting_input"]), ) .values( status="cancel_requested", version=RoundtableJobRow.version + 1, updated_at=now, ) ) continue if status != "awaiting_input" and not _job_is_live( lease_until, updated_at, now, live_after, ): continue job_key = compute_active_dedupe_key(draft_id, user_id, task_id) if job_key and job_key in durable_scopes: # Foreground → background handoff can overlap for a few # milliseconds. Both represent one room for the same scope. continue if not job_key: job_key = f"job:{job_id}" keys.add(job_key) scope_key = compute_active_dedupe_key(draft_id, user_id, task_id) if scope_key: durable_scopes.add(scope_key) return keys, durable_scopes, hard_expired_keys async def _ensure_gate_row(self) -> None: """Create the portable database mutex row once, tolerating races.""" now = datetime.now(UTC) async with self._sf() as session: if await session.get(RoundtableRoomLeaseRow, GATE_ROW_KEY) is not None: return session.add( RoundtableRoomLeaseRow( room_key=GATE_ROW_KEY, draft_id=None, user_id=None, task_id=None, holder_user_id=None, lease_until=None, created_at=now, updated_at=now, ) ) try: await session.commit() except IntegrityError: await session.rollback() async def _upsert_lease( self, session: AsyncSession, *, room_key: str, draft_id: str | None, user_id: str | None, task_id: str | None, holder_user_id: str | None, now: datetime, lease_seconds: int = DEFAULT_LEASE_SECONDS, ) -> None: row = await session.get(RoundtableRoomLeaseRow, room_key) lease_until = now + timedelta(seconds=normalize_lease_seconds(lease_seconds)) if row is None: session.add( RoundtableRoomLeaseRow( room_key=room_key, draft_id=draft_id, user_id=user_id, task_id=task_id, holder_user_id=holder_user_id, lease_until=lease_until, created_at=now, updated_at=now, ) ) return row.draft_id = draft_id row.user_id = user_id row.task_id = task_id row.holder_user_id = holder_user_id row.lease_until = lease_until row.updated_at = now @staticmethod def _decision( *, ok: bool, enabled: bool, max_rooms: int, active_count: int, renewed: bool, code: str, message: str, ) -> dict[str, Any]: return { "ok": ok, "enabled": enabled, "max_rooms": max_rooms, "active_count": active_count, "renewed": renewed, "code": code, "message": message, }