from __future__ import annotations import asyncio import time from typing import Any from yuxi.utils.logging_config import logger class ThreadBinding: def __init__( self, thread_key: str, subagent_id: str | None = None, acp_binding: str | None = None, idle_timeout_ms: int = 86_400_000, ): self.thread_key = thread_key self.subagent_id = subagent_id self.acp_binding = acp_binding self.idle_timeout_ms = idle_timeout_ms self.last_activity: float = time.monotonic() self.created_at: float = time.monotonic() def touch(self) -> None: self.last_activity = time.monotonic() def is_expired(self) -> bool: elapsed = (time.monotonic() - self.last_activity) * 1000 return elapsed > self.idle_timeout_ms class ThreadBindingManager: def __init__(self, config: dict[str, Any] | None = None): cfg = config or {} bindings_cfg = cfg.get("thread_bindings", {}) self._enabled = bindings_cfg.get("enabled", False) self._default_idle_timeout_ms = bindings_cfg.get("idle_timeout_ms", 86_400_000) self._cleanup_interval = bindings_cfg.get("cleanup_interval", 300) self._bindings: dict[str, ThreadBinding] = {} self._cleanup_task: asyncio.Task | None = None @property def enabled(self) -> bool: return self._enabled def bind( self, thread_key: str, subagent_id: str | None = None, acp_binding: str | None = None, idle_timeout_ms: int | None = None, ) -> ThreadBinding: timeout = idle_timeout_ms or self._default_idle_timeout_ms binding = ThreadBinding( thread_key=thread_key, subagent_id=subagent_id, acp_binding=acp_binding, idle_timeout_ms=timeout, ) self._bindings[thread_key] = binding logger.debug(f"[Telegram] Thread bound: {thread_key} -> subagent={subagent_id}") return binding def unbind(self, thread_key: str) -> bool: if thread_key in self._bindings: del self._bindings[thread_key] logger.debug(f"[Telegram] Thread unbound: {thread_key}") return True return False def get_binding(self, thread_key: str) -> ThreadBinding | None: binding = self._bindings.get(thread_key) if binding and binding.is_expired(): self.unbind(thread_key) return None return binding def get_subagent_id(self, thread_key: str) -> str | None: binding = self.get_binding(thread_key) return binding.subagent_id if binding else None def touch(self, thread_key: str) -> None: binding = self._bindings.get(thread_key) if binding: binding.touch() def cleanup_expired(self) -> int: expired = [k for k, v in self._bindings.items() if v.is_expired()] for k in expired: self.unbind(k) if expired: logger.info(f"[Telegram] Cleaned up {len(expired)} expired thread bindings") return len(expired) async def start_cleanup_loop(self) -> None: if not self._enabled: return async def _loop(): while True: await asyncio.sleep(self._cleanup_interval) self.cleanup_expired() self._cleanup_task = asyncio.create_task(_loop()) async def stop_cleanup_loop(self) -> None: if self._cleanup_task and not self._cleanup_task.done(): self._cleanup_task.cancel() try: await self._cleanup_task except asyncio.CancelledError: pass self._cleanup_task = None def get_all_bindings(self) -> dict[str, dict[str, Any]]: return { k: { "thread_key": v.thread_key, "subagent_id": v.subagent_id, "acp_binding": v.acp_binding, "idle_remaining_ms": max(0, int(v.idle_timeout_ms - (time.monotonic() - v.last_activity) * 1000)), } for k, v in self._bindings.items() }