Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/ai/ai_run_hub.py
2026-08-21 14:57:55 +00:00

238 lines
8.3 KiB
Python

"""Hub اجرای زندهٔ ایجنت — مستقل از اتصال SSE کلاینت.
الگوی ChatGPT/Claude/Cursor: producer روی سرور ادامه می‌یابد؛ مرورگر فقط
مشترک رویدادهاست. قطع SSE تولید را متوقف نمی‌کند؛ توقف صریح با cancel است.
بافر رویداد در حافظه و Redis است. اگر run روی worker دیگری زنده باشد،
subscribe ابتدا بافر را replay می‌کند و بعد وضعیت DB را poll می‌کند.
"""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Awaitable, Callable, Optional
from app.services.ai.ai_constants import LIVE_RUN_STALE_SEC
from app.services.ai.ai_sse_event_buffer import events_after
from app.services.ai.ai_stream_helpers import SseEventSequencer, format_sse_payload
def replay_buffered_events(
run_id: str, last_event_id: int
) -> tuple[list[tuple[int, dict[str, Any]]], int, bool]:
"""رویدادهای بافر پس از last_event_id؛ برای worker دیگر بدون hub زنده."""
events = events_after(run_id, int(last_event_id or 0))
cursor = int(last_event_id or 0)
terminal = False
out: list[tuple[int, dict[str, Any]]] = []
for event_id, payload in events:
eid = int(event_id)
out.append((eid, dict(payload)))
if eid > cursor:
cursor = eid
if _is_terminal_payload(payload):
terminal = True
break
return out, cursor, terminal
logger = logging.getLogger(__name__)
ProducerFn = Callable[["LiveAgentRun"], Awaitable[None]]
@dataclass
class LiveAgentRun:
run_id: str
session_id: int
user_id: int
sequencer: SseEventSequencer
subscribers: set[asyncio.Queue] = field(default_factory=set)
task: Optional[asyncio.Task] = None
cancel_requested: bool = False
finished: bool = False
started_at: float = field(default_factory=time.monotonic)
spawned: asyncio.Event = field(default_factory=asyncio.Event)
def publish(self, data: dict[str, Any]) -> tuple[int, dict[str, Any]]:
event_id, payload = self.sequencer.push(data)
for queue in list(self.subscribers):
try:
queue.put_nowait((event_id, payload))
except asyncio.QueueFull:
logger.warning(
"SSE subscriber queue full run_id=%s; client can reconnect",
self.run_id,
)
return event_id, payload
def notify_finished(self) -> None:
self.finished = True
for queue in list(self.subscribers):
try:
queue.put_nowait(None)
except asyncio.QueueFull:
pass
class AgentRunHub:
def __init__(self) -> None:
self._runs: dict[str, LiveAgentRun] = {}
self._lock = asyncio.Lock()
self._mailbox: Optional[asyncio.Queue] = None
self._supervisor: Optional[asyncio.Task] = None
def start_supervisor(self) -> None:
"""باید در startup اپ صدا زده شود تا خارج از cancel scope درخواست بماند."""
if self._supervisor is not None and not self._supervisor.done():
return
self._mailbox = asyncio.Queue()
self._supervisor = asyncio.create_task(
self._supervise_forever(), name="ai-run-hub-supervisor"
)
async def _supervise_forever(self) -> None:
assert self._mailbox is not None
while True:
live, producer = await self._mailbox.get()
live.task = asyncio.create_task(
self._run_producer(live, producer),
name=f"ai-run-{live.run_id}",
)
live.spawned.set()
async def _run_producer(self, live: LiveAgentRun, producer: ProducerFn) -> None:
try:
await producer(live)
except asyncio.CancelledError:
if live.cancel_requested:
raise
logger.warning(
"agent run producer cancelled without user stop run_id=%s",
live.run_id,
)
raise
except Exception:
logger.exception("agent run producer crashed run_id=%s", live.run_id)
finally:
live.notify_finished()
await asyncio.sleep(1.5)
async with self._lock:
if self._runs.get(live.run_id) is live:
self._runs.pop(live.run_id, None)
def get(self, run_id: str) -> Optional[LiveAgentRun]:
return self._runs.get(str(run_id))
def is_live(self, run_id: Optional[str]) -> bool:
if not run_id:
return False
live = self._runs.get(str(run_id))
return live is not None and not live.finished
def request_cancel(self, run_id: str) -> bool:
live = self.get(run_id)
if live is None or live.finished:
return False
live.cancel_requested = True
if live.task is not None and not live.task.done():
live.task.cancel()
return True
async def attach_or_start(
self,
*,
run_id: str,
session_id: int,
user_id: int,
producer: ProducerFn,
start_id: int = 0,
) -> LiveAgentRun:
"""اگر producer زنده است همان را برگردان؛ وگرنه یکی بساز و شروع کن."""
self.start_supervisor()
async with self._lock:
existing = self._runs.get(run_id)
if existing is not None and not existing.finished:
return existing
live = LiveAgentRun(
run_id=run_id,
session_id=session_id,
user_id=user_id,
sequencer=SseEventSequencer(run_id=run_id, start_id=int(start_id or 0)),
)
self._runs[run_id] = live
assert self._mailbox is not None
await self._mailbox.put((live, producer))
await live.spawned.wait()
return live
async def iter_formatted(
self,
run_id: str,
last_event_id: int = 0,
) -> AsyncIterator[str]:
"""بازپخش بافر + رویدادهای زنده. قطع این iterator روی producer اثر ندارد."""
live = self.get(run_id)
queue: Optional[asyncio.Queue] = None
if live is not None:
queue = asyncio.Queue(maxsize=500)
live.subscribers.add(queue)
seen: set[int] = set()
try:
for event_id, payload in events_after(run_id, int(last_event_id or 0)):
seen.add(int(event_id))
yield format_sse_payload(payload, event_id=event_id)
if _is_terminal_payload(payload):
return
if live is None or live.finished:
return
assert queue is not None
while True:
item = await queue.get()
if item is None:
return
event_id, payload = item
if int(event_id) <= int(last_event_id or 0) or int(event_id) in seen:
if _is_terminal_payload(payload):
return
continue
seen.add(int(event_id))
yield format_sse_payload(payload, event_id=event_id)
if _is_terminal_payload(payload):
return
finally:
if live is not None and queue is not None:
live.subscribers.discard(queue)
def _is_terminal_payload(payload: dict[str, Any]) -> bool:
if payload.get("done") is True:
return True
return payload.get("type") == "error"
def is_running_row_fresh(updated_at: Optional[Any], *, now: Optional[float] = None) -> bool:
if updated_at is None:
return False
try:
if hasattr(updated_at, "timestamp"):
from datetime import timezone
dt = updated_at
if getattr(dt, "tzinfo", None) is None:
dt = dt.replace(tzinfo=timezone.utc)
ts = dt.timestamp()
else:
ts = float(updated_at)
except (TypeError, ValueError, OSError):
return False
current = now if now is not None else time.time()
return (current - ts) < LIVE_RUN_STALE_SEC
agent_run_hub = AgentRunHub()