diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index b7f1e6d..8ad2bdd 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -143,6 +143,30 @@ def do_save_settings(settings: Dict[str, Any]) -> Tuple[bool, str]: except Exception as e: return False, f"Не удалось сохранить настройки: {e}" + from antigravity_provider.router.settings_service import invalidate_settings_cache + invalidate_settings_cache() + + try: + from antigravity_provider.router.router_config import load_router_config, save_router_config + rcfg = load_router_config() + updated_rcfg = False + if "quota_threshold_percent" in settings: + rcfg.quota_threshold_percent = float(settings["quota_threshold_percent"]) + updated_rcfg = True + if "quota_threshold_action" in settings: + rcfg.quota_threshold_action = str(settings["quota_threshold_action"]) + updated_rcfg = True + if updated_rcfg: + save_router_config(rcfg) + except Exception: + pass + + try: + from antigravity_provider.router.state_store import HubStateStore + HubStateStore.get().refresh(force_scan=True) + except Exception: + pass + from antigravity_provider.router.quota_collector import AccountQuotaService AccountQuotaService.get().set_refresh_interval(int(settings.get("quota_refresh_interval_sec", 300))) diff --git a/src/antigravity_provider/router/auto_assigner.py b/src/antigravity_provider/router/auto_assigner.py index 9e7b456..a9fde50 100644 --- a/src/antigravity_provider/router/auto_assigner.py +++ b/src/antigravity_provider/router/auto_assigner.py @@ -104,7 +104,10 @@ class AutoAssigner: @staticmethod def get_display_name_and_role(profile_id: str) -> Tuple[str, str, str]: """Get human-readable display name, logical role, and tier for a profile.""" - return DEFAULT_SLOT_ROLES.get(profile_id, (profile_id, "worker", "primary")) + if profile_id in DEFAULT_SLOT_ROLES: + return DEFAULT_SLOT_ROLES[profile_id] + clean_name = profile_id.replace("-", " ").title() + return (clean_name, "worker", "primary") @staticmethod def check_duplicate_identity(provider: str, email_or_id: str, exclude_profile_id: Optional[str] = None) -> Optional[str]: @@ -138,8 +141,13 @@ class AutoAssigner: @staticmethod def find_free_slot(provider: str, requested_role: str = "auto") -> Optional[str]: - """Find the optimal free internal profile slot for a provider.""" + """Find the optimal free internal profile slot for a provider. + + When all predefined candidate slots are authenticated/occupied, dynamically + generates the next free profile ID without capping account count (P0-1). + """ config = load_router_config() + provider_norm = (provider or "").strip().lower() provider_slots = { "antigravity": [ @@ -147,9 +155,13 @@ class AutoAssigner: "ag-spare-1", "ag-spare-2", "ag-cold-1", "ag-cold-2", "ag-cold-3" ], "openai-codex": ["codex-orch", "codex-worker-1", "codex-worker-2"], + "codex": ["codex-orch", "codex-worker-1", "codex-worker-2"], "opencode-go": ["opengo-1", "opengo-2", "opengo-3"], + "opencode": ["opengo-1", "opengo-2", "opengo-3"], "claude": ["claude-orch", "claude-worker-1", "claude-worker-2"], + "anthropic": ["claude-orch", "claude-worker-1", "claude-worker-2"], "grok": ["grok-orch", "grok-worker-1", "grok-worker-2"], + "xai": ["grok-orch", "grok-worker-1", "grok-worker-2"], "local": ["local-1", "local-2"], "local-llm": ["local-1", "local-2"], "llama.cpp": ["local-1", "local-2"], @@ -157,18 +169,17 @@ class AutoAssigner: "vllm": ["local-1", "local-2"], } - candidates = list(provider_slots.get(provider, [])) + candidates = list(provider_slots.get(provider_norm, [])) # Priority based on requested role if requested_role == "orchestrator": - if provider == "openai-codex" and "codex-orch" in candidates: + if provider_norm in ("openai-codex", "codex") and "codex-orch" in candidates: candidates.remove("codex-orch") candidates.insert(0, "codex-orch") - elif provider == "antigravity" and "ag-orch-fallback" in candidates: + elif provider_norm == "antigravity" and "ag-orch-fallback" in candidates: candidates.remove("ag-orch-fallback") candidates.insert(0, "ag-orch-fallback") - - # Find first slot without saved auth that exists in config + # 1. First check existing candidate profiles already in config for pid in candidates: pcfg = config.get_profile(pid) if not pcfg: @@ -177,6 +188,44 @@ class AutoAssigner: if not status.get("authenticated"): return pid + # 2. Check remaining predefined slots and ensure definition + for pid in candidates: + status = ProfileAuthManager.get_profile_status(provider, pid) + if not status.get("authenticated"): + AutoAssigner.ensure_profile_definition(provider, pid) + return pid + + # 3. Predefined slots occupied: dynamically generate unlimited candidates + def _generate_candidates(prov: str): + p = prov.lower() + if p == "antigravity": + for i in range(5, 200): + yield f"ag-w{i}" + elif p in ("openai-codex", "codex"): + for i in range(4, 200): + yield f"codex-{i}" + elif p in ("opencode-go", "opencode"): + for i in range(4, 200): + yield f"opengo-{i}" + elif p in ("claude", "anthropic"): + for i in range(3, 200): + yield f"claude-worker-{i}" + elif p in ("grok", "xai"): + for i in range(3, 200): + yield f"grok-worker-{i}" + elif p in ("local", "local-llm", "llama.cpp", "ollama", "vllm"): + for i in range(3, 200): + yield f"local-{i}" + else: + for i in range(1, 200): + yield f"{p}-{i}" + + for dynamic_pid in _generate_candidates(provider_norm): + status = ProfileAuthManager.get_profile_status(provider, dynamic_pid) + if not status.get("authenticated"): + AutoAssigner.ensure_profile_definition(provider, dynamic_pid) + return dynamic_pid + return None @staticmethod @@ -206,7 +255,7 @@ class AutoAssigner: "ollama": ["reviewer", "coding", "reasoning", "fast", "research"], "vllm": ["reviewer", "coding", "reasoning", "fast", "research"], } - capabilities = capabilities_map.get(provider, []) + capabilities = capabilities_map.get(provider, ["coding", "reasoning"]) config.profiles[profile_id] = RouterProfileConfig( profile_id=profile_id, @@ -300,9 +349,9 @@ class AutoAssigner: @staticmethod def auto_assign_all() -> Dict[str, Any]: - """Automatically distribute all authenticated profiles across canonical router roles.""" + """Automatically distribute all authenticated profiles across canonical router roles (P0-4).""" config = load_router_config() - authenticated_profiles = [] + authenticated_profiles: List[Tuple[str, RouterProfileConfig]] = [] for pid, pcfg in config.profiles.items(): if not pcfg.enabled: continue @@ -310,13 +359,134 @@ class AutoAssigner: if st.get("authenticated"): authenticated_profiles.append((pid, pcfg)) - changes = [] - canonical_roles_order = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"] - for idx, (pid, pcfg) in enumerate(authenticated_profiles): - target_role = canonical_roles_order[idx % len(canonical_roles_order)] - ok, msg = AutoAssigner.assign_profile_to_role(pid, target_role, is_primary=(idx < len(canonical_roles_order))) - if ok: - changes.append({"profile_id": pid, "role": target_role, "message": msg}) + if not authenticated_profiles: + return { + "success": False, + "message": "Нет подключённых аккаунтов для распределения", + "total_authenticated": 0, + "assigned_count": 0, + "changes": [], + } + + canonical_roles = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"] + changes: List[Dict[str, Any]] = [] + + # Ensure canonical roles exist in config + for rname in canonical_roles: + if rname not in config.roles: + config.roles[rname] = RolePolicy(role_name=rname) + + def _norm_prov(p: str) -> str: + p = (p or "").lower().strip() + if p in ("openai-codex", "codex"): + return "codex" + if p in ("opencode-go", "opencode"): + return "opencode" + if p in ("local-llm", "llama.cpp", "ollama", "vllm", "local"): + return "local" + if p in ("claude", "anthropic"): + return "claude" + if p in ("grok", "xai"): + return "grok" + return p + + unique_providers = set(_norm_prov(pcfg.provider) for _, pcfg in authenticated_profiles) + + if len(authenticated_profiles) == 1: + # Case 1: Exactly 1 account connected -> assign as primary to all 6 roles + single_pid, _ = authenticated_profiles[0] + for role_name in canonical_roles: + config.roles[role_name].preferred_chain = [single_pid] + changes.append({ + "profile_id": single_pid, + "role": role_name, + "message": f"Профиль '{single_pid}' назначен основным во все 6 ролей", + }) + elif len(unique_providers) == 1: + # Case 2: Multiple accounts of the same provider -> distribute / rotate across roles + pids = [pid for pid, _ in authenticated_profiles] + num_accs = len(pids) + for idx, role_name in enumerate(canonical_roles): + primary_pid = pids[idx % num_accs] + fallbacks = [p for p in pids if p != primary_pid] + config.roles[role_name].preferred_chain = [primary_pid] + fallbacks + changes.append({ + "profile_id": primary_pid, + "role": role_name, + "message": f"Профиль '{primary_pid}' назначен на роль '{role_name}' с ротацией квот", + }) + else: + # Case 3: Multiple providers connected -> distribute by role provider preferences + role_provider_preferences = { + "orchestrator": ["codex", "antigravity", "opencode", "claude", "grok", "local"], + "coder-primary": ["codex", "antigravity", "opencode", "claude", "grok", "local"], + "coder-secondary": ["codex", "antigravity", "opencode", "claude", "grok", "local"], + "reviewer": ["codex", "opencode", "antigravity", "claude", "grok", "local"], + "research": ["opencode", "antigravity", "grok", "claude", "codex", "local"], + "fast": ["opencode", "antigravity", "local", "grok", "codex", "claude"], + } + + by_prov: Dict[str, List[str]] = {} + for pid, pcfg in authenticated_profiles: + norm = _norm_prov(pcfg.provider) + by_prov.setdefault(norm, []).append(pid) + + prov_cursors: Dict[str, int] = {k: 0 for k in by_prov} + + for role_name in canonical_roles: + pref_order = role_provider_preferences.get(role_name, ["codex", "antigravity", "opencode"]) + chain: List[str] = [] + + # Find primary for this role + chosen_primary = None + for prov in pref_order: + if prov in by_prov and by_prov[prov]: + acc_list = by_prov[prov] + cur = prov_cursors[prov] + chosen_primary = acc_list[cur % len(acc_list)] + prov_cursors[prov] = cur + 1 + break + + if not chosen_primary: + all_pids = [pid for pid, _ in authenticated_profiles] + chosen_primary = all_pids[0] + + chain.append(chosen_primary) + + # Add remaining profiles as fallbacks in preference order + for prov in pref_order: + if prov in by_prov: + for p in by_prov[prov]: + if p not in chain: + chain.append(p) + for pid, _ in authenticated_profiles: + if pid not in chain: + chain.append(pid) + + config.roles[role_name].preferred_chain = chain + changes.append({ + "profile_id": chosen_primary, + "role": role_name, + "message": f"Профиль '{chosen_primary}' назначен основным на роль '{role_name}'", + }) + + save_router_config(config) + + try: + from antigravity_provider.router.state_store import HubStateStore + HubStateStore.get().refresh(force_scan=True) + except Exception: + pass + + try: + from antigravity_provider.router.unified_health import EventLogService + EventLogService.get().log( + "routing", + f"Авто-распределение завершено: {len(authenticated_profiles)} аккаунтов распределены по 6 ролям.", + level="info", + ) + except Exception: + pass return { "success": True, diff --git a/src/antigravity_provider/router/health_tracker.py b/src/antigravity_provider/router/health_tracker.py index d302cc7..8ab2e66 100644 --- a/src/antigravity_provider/router/health_tracker.py +++ b/src/antigravity_provider/router/health_tracker.py @@ -399,31 +399,68 @@ class HealthTracker: self._save_state() def reconcile_measured_quota(self, profile_id: str, remaining_by_family: Dict[str, float]) -> bool: - """Clear stale quota-exhausted flags when the provider reports live capacity. + """Evaluate measured quota against configured threshold and recover when capacity restores. A successful quota read is authoritative for quota exhaustion, but it must not erase unrelated authentication or runtime failures. """ - measured = {family: float(value) for family, value in remaining_by_family.items()} + measured = {family: float(value) for family, value in remaining_by_family.items() if value is not None} if not measured: return False + + from antigravity_provider.router.settings_service import get_hub_settings + settings = get_hub_settings() + threshold = float(settings.get("quota_threshold_percent", 10.0)) + action = str(settings.get("quota_threshold_action", "notify")).strip().lower() + with self._lock: record = self.get_or_create(profile_id) changed = False + now = time.time() + for family, remaining in measured.items(): family_record = record.families.get(family) - if remaining > 0 and family_record and family_record.state == QUOTA_EXHAUSTED: - family_record.state = HEALTHY - family_record.reset_at = None - family_record.reason = None - family_record.last_error = None - family_record.simulated = False - changed = True - if all(value > 0 for value in measured.values()) and record.overall_state == QUOTA_EXHAUSTED: + if remaining <= threshold: + if action == "switch": + if family not in record.families: + record.families[family] = FamilyHealthRecord(family=family) + frec = record.families[family] + if frec.state != QUOTA_EXHAUSTED: + frec.state = QUOTA_EXHAUSTED + frec.reset_at = now + 1800 + frec.reason = f"Остаток квоты {remaining:.1f}% <= порога {threshold:.1f}%" + frec.last_error = frec.reason + changed = True + elif action == "notify": + try: + from antigravity_provider.router.unified_health import EventLogService + EventLogService.get().log( + "quota", + f"Внимание: остаток квоты профиля {profile_id} ({family}) составляет {remaining:.1f}% (порог: {threshold:.1f}%).", + level="warning", + ) + except Exception: + pass + elif remaining > threshold: + if family_record and family_record.state == QUOTA_EXHAUSTED: + family_record.state = HEALTHY + family_record.reset_at = None + family_record.reason = None + family_record.last_error = None + family_record.simulated = False + changed = True + + if all(value > threshold for value in measured.values()) and record.overall_state == QUOTA_EXHAUSTED: record.overall_state = HEALTHY record.last_error = None record.simulated = False changed = True + elif any(value <= threshold for value in measured.values()) and action == "switch": + if record.overall_state == HEALTHY: + record.overall_state = QUOTA_EXHAUSTED + record.last_error = f"Остаток квоты ниже порога {threshold:.1f}%" + changed = True + if changed: self._save_state() return changed diff --git a/src/antigravity_provider/router/quota_collector.py b/src/antigravity_provider/router/quota_collector.py index 1c95fa3..704d0e4 100644 --- a/src/antigravity_provider/router/quota_collector.py +++ b/src/antigravity_provider/router/quota_collector.py @@ -166,12 +166,12 @@ class AccountQuotaService: with self._cache_lock: self._snapshots[key] = snap - if snap.source == "provider_api": + if snap.source in ("provider_api", "local_provider") or snap.buckets: measured_by_family: dict[str, float] = {} for bucket in snap.buckets: - family = bucket.model_family + family = bucket.model_family or "default" remaining = bucket.remaining_percent - if not family or remaining is None: + if remaining is None: continue measured_by_family[family] = min(measured_by_family.get(family, 100.0), float(remaining)) if measured_by_family: @@ -179,8 +179,13 @@ class AccountQuotaService: from .router_engine import get_router_engine get_router_engine().health.reconcile_measured_quota(profile_id, measured_by_family) - except Exception as exc: - logger.debug("Could not reconcile live quota health for %s: %s", profile_id, exc) + except Exception: + try: + from .health_tracker import HealthTracker + + HealthTracker().reconcile_measured_quota(profile_id, measured_by_family) + except Exception as exc: + logger.debug("Could not reconcile live quota health for %s: %s", profile_id, exc) # Notify listeners for listener in list(self._listeners): diff --git a/src/antigravity_provider/router/router_config.py b/src/antigravity_provider/router/router_config.py index 86db45a..386367d 100644 --- a/src/antigravity_provider/router/router_config.py +++ b/src/antigravity_provider/router/router_config.py @@ -42,6 +42,8 @@ class RouterConfig: cooldown_base_seconds: int = 300 cooldown_max_seconds: int = 3600 session_affinity_ttl_seconds: int = 1800 + quota_threshold_percent: float = 10.0 + quota_threshold_action: str = "notify" # "notify" | "switch" roles: dict[str, RolePolicy] = field(default_factory=dict) profiles: dict[str, RouterProfileConfig] = field(default_factory=dict) pricing: dict[str, dict[str, float]] = field(default_factory=dict) @@ -387,6 +389,13 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig: session_ttl = int(r_block.get("session_affinity_ttl_seconds", data.get("session_affinity_ttl_seconds", 1800))) quota_cooldown = int(r_block.get("quota_cooldown_seconds", data.get("quota_cooldown_seconds", 1800))) rate_cooldown = int(r_block.get("rate_limit_cooldown_seconds", data.get("rate_limit_cooldown_seconds", 60))) + try: + quota_threshold_percent = float(r_block.get("quota_threshold_percent", data.get("quota_threshold_percent", 10.0))) + except (ValueError, TypeError): + quota_threshold_percent = 10.0 + quota_threshold_action = str(r_block.get("quota_threshold_action", data.get("quota_threshold_action", "notify"))).strip().lower() + if quota_threshold_action not in ("notify", "switch"): + quota_threshold_action = "notify" # Automatic Idempotent Migration (P0-0.1) # Merge missing default profiles and roles into loaded user configuration @@ -435,6 +444,8 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig: cooldown_base_seconds=cooldown_base, cooldown_max_seconds=cooldown_max, session_affinity_ttl_seconds=session_ttl, + quota_threshold_percent=quota_threshold_percent, + quota_threshold_action=quota_threshold_action, roles=roles, profiles=profiles, pricing=pricing, @@ -453,6 +464,8 @@ def load_router_config(config_path: Optional[Path] = None) -> RouterConfig: cooldown_base_seconds=cooldown_base, cooldown_max_seconds=cooldown_max, session_affinity_ttl_seconds=session_ttl, + quota_threshold_percent=quota_threshold_percent, + quota_threshold_action=quota_threshold_action, roles=roles, profiles=profiles, pricing=pricing, @@ -509,6 +522,8 @@ def save_router_config(config: RouterConfig, config_path: Optional[Path] = None) "cooldown_base_seconds": config.cooldown_base_seconds, "cooldown_max_seconds": config.cooldown_max_seconds, "session_affinity_ttl_seconds": config.session_affinity_ttl_seconds, + "quota_threshold_percent": config.quota_threshold_percent, + "quota_threshold_action": config.quota_threshold_action, }) data = { diff --git a/src/antigravity_provider/router/settings_service.py b/src/antigravity_provider/router/settings_service.py index 6fcd7eb..0e7182e 100644 --- a/src/antigravity_provider/router/settings_service.py +++ b/src/antigravity_provider/router/settings_service.py @@ -20,6 +20,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "release_channel": "stable", "model_timeout_seconds": 60, "monitoring_interval_seconds": 30, + "quota_threshold_percent": 10.0, + "quota_threshold_action": "notify", } @@ -86,6 +88,16 @@ def get_hub_settings() -> Dict[str, Any]: except (ValueError, TypeError): merged["monitoring_interval_seconds"] = 30 + try: + merged["quota_threshold_percent"] = float(merged.get("quota_threshold_percent", 10.0)) + except (ValueError, TypeError): + merged["quota_threshold_percent"] = 10.0 + + action = str(merged.get("quota_threshold_action", "notify")).strip().lower() + if action not in ("notify", "switch"): + action = "notify" + merged["quota_threshold_action"] = action + _SETTINGS_CACHE = dict(merged) _SETTINGS_CACHE_MTIME = current_mtime _SETTINGS_CACHE_PATH = sfile_str diff --git a/src/antigravity_provider/router/unified_health.py b/src/antigravity_provider/router/unified_health.py index 2bab523..2cc157b 100644 --- a/src/antigravity_provider/router/unified_health.py +++ b/src/antigravity_provider/router/unified_health.py @@ -590,6 +590,24 @@ class UnifiedHealthService: dead_roles += 1 warnings.append(f"Роль '{rname}' не имеет рабочих аккаунтов (все исчерпаны).") + # Quota threshold warnings (P0-5) + try: + from antigravity_provider.router.quota_collector import AccountQuotaService + from antigravity_provider.router.settings_service import get_hub_settings + settings = get_hub_settings() + threshold = float(settings.get("quota_threshold_percent", 10.0)) + for profile in configured_profiles: + if profile.auth_state == "AUTHENTICATED": + snap = AccountQuotaService.get().get_snapshot(profile.provider, profile.profile_id) + if snap and snap.buckets: + for b in snap.buckets: + if b.remaining_percent is not None and b.remaining_percent <= threshold: + warnings.append( + f"Квота аккаунта {profile.profile_id} ({b.display_name or profile.provider}) ниже порога {threshold:.1f}%: {b.remaining_percent:.1f}%." + ) + except Exception: + pass + # Determine overall state if dead_roles > 0: state = READINESS_CRITICAL diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index aeef01a..a304b70 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -36,18 +36,9 @@ app.add_middleware( def _web_settings() -> Dict[str, Any]: - """Настройки веб-API живут в hub_settings.json, а не в RouterConfig. - - Прежняя версия читала config.hub — такого атрибута у RouterConfig нет, - поэтому /api/snapshot и /api/action падали с 500, а run_server не - поднимался вовсе. Работал только /api/health, у которого нет проверки - авторизации, — из-за чего дефект и выглядел как рабочий сервер. - """ - settings_file = paths.get_hermes_home() / "hub_settings.json" - try: - return json.loads(settings_file.read_text(encoding="utf-8")) - except (OSError, ValueError, TypeError): - return {} + """Настройки веб-API живут в hub_settings.json, а не в RouterConfig.""" + from antigravity_provider.router.settings_service import get_hub_settings + return get_hub_settings() def get_auth_token(x_hub_token: str = Header(None)) -> bool: diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index d6adc46..aa9028f 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -129,6 +129,13 @@ function initEventListeners() { }); } + const btnAutoAssign = document.getElementById('btn-auto-assign'); + if (btnAutoAssign) { + btnAutoAssign.addEventListener('click', () => { + executeAction('auto_assign_all', {}); + }); + } + if (elements.modalCloseBtn) elements.modalCloseBtn.addEventListener('click', closeModal); if (elements.modalBackdrop) { elements.modalBackdrop.addEventListener('click', (e) => { @@ -290,6 +297,28 @@ function startPolling() { } } +// Подключён ли профиль на самом деле. +// +// A26 определял это как «health_state не равен not_configured», а поле +// authenticated в модели вообще отсутствует, поэтому первая половина условия +// была мертва. Через фильтр проходили холодный резерв (health_state +// "disabled") и непроверенные пустые слоты: при нуле настоящих аккаунтов +// страница показывала три карточки «Холодный резерв», а «Обзор» предлагал +// назначать роли на пустые слоты — то самое мышление слотами, ради отмены +// которого задание и делалось. +// +// Authoritative признак — auth_state: у подключённого AUTHENTICATED, у +// пустого слота и у холодного резерва NOT_CONFIGURED. Состояния +// AUTH_REQUIRED и AUTH_EXPIRED означают подключённый аккаунт, которому нужен +// повторный вход, — их показываем. +function isConnectedProfile(p) { + if (!p) return false; + const st = String(p.auth_state || '').toUpperCase(); + if (st) return st !== 'NOT_CONFIGURED'; + // Запасной путь, если поле не пришло: судим по наличию опознанного аккаунта. + return Boolean(p.email); +} + // ── КОПИРОВАНИЕ В БУФЕР ── // // navigator.clipboard существует только в защищённом контексте: HTTPS или @@ -462,10 +491,14 @@ async function executeAction(actionName, actionData = {}) { function updateGlobalHeader() { if (!currentSnapshot) return; - const totalAccounts = Object.keys(currentSnapshot.all_profiles || {}).length; - if (elements.navAccountsCount) elements.navAccountsCount.textContent = totalAccounts; - const readiness = currentSnapshot.readiness || {}; + const allProfiles = Object.values(currentSnapshot.all_profiles || {}); + const connectedAccounts = readiness.accounts_connected_count ?? allProfiles.filter( + (p) => isConnectedProfile(p) + ).length; + + if (elements.navAccountsCount) elements.navAccountsCount.textContent = connectedAccounts; + const isHealthy = readiness.state === 'healthy'; const readyRoles = readiness.roles_ready_count || 0; const totalRoles = readiness.total_roles || 6; @@ -489,8 +522,8 @@ function updateGlobalHeader() { if (kpiReadiness) kpiReadiness.textContent = readiness.title_ru || 'Работает'; if (kpiSummary) kpiSummary.textContent = readiness.summary_ru || 'Все маршруты доступны'; - if (kpiTotalAccounts) kpiTotalAccounts.textContent = totalAccounts; - if (kpiAccountsSub) kpiAccountsSub.textContent = `Подключено: ${readiness.accounts_connected_count || totalAccounts}`; + if (kpiTotalAccounts) kpiTotalAccounts.textContent = connectedAccounts; + if (kpiAccountsSub) kpiAccountsSub.textContent = `Подключено: ${connectedAccounts}`; if (kpiReadyRoles) kpiReadyRoles.textContent = `${readyRoles}/${totalRoles}`; if (kpiRolesSub) kpiRolesSub.textContent = `${readyRoles} из ${totalRoles} ролей маршрутизации активны`; if (kpiProvidersCount) kpiProvidersCount.textContent = (currentSnapshot.providers || []).length || 5; @@ -525,12 +558,32 @@ function renderCurrentView() { } // ═══════════════════════════════════════════════════════════════ -// 1. ACCOUNTS VIEW (Compact Fixed-Height Cards & Quotas) +// 1. ACCOUNTS VIEW (P0-2 Only Connected Accounts & Empty State) // ═══════════════════════════════════════════════════════════════ function renderAccountsView() { const container = elements.accountsContainer; if (!container || !currentSnapshot) return; + const allProfiles = Object.values(currentSnapshot.all_profiles || {}); + const totalConnectedInSystem = allProfiles.filter( + (p) => isConnectedProfile(p) + ).length; + + if (totalConnectedInSystem === 0) { + container.innerHTML = ` +
+
👥
+

Нет подключённых аккаунтов

+

Подключите ваш первый аккаунт провайдера ИИ для распределения ролей и работы с Hermes Hub.

+ +
+ `; + if (elements.accountsStatsSummary) { + elements.accountsStatsSummary.innerHTML = 'Показано: 0 из 0 подключённых аккаунтов'; + } + return; + } + const searchQuery = (elements.accountsSearch ? elements.accountsSearch.value : '').trim().toLowerCase(); const providerFilter = elements.filterProvider ? elements.filterProvider.value : 'all'; const healthFilter = elements.filterHealth ? elements.filterHealth.value : 'all'; @@ -557,6 +610,9 @@ function renderAccountsView() { if (providerFilter !== 'all' && providerFilter !== providerId) continue; const filtered = profiles.filter((p) => { + const isConnected = isConnectedProfile(p); + if (!isConnected) return false; + totalProfiles++; const matchesSearch = !searchQuery || @@ -575,9 +631,6 @@ function renderAccountsView() { return matchesSearch && matchesHealth; }); - // Подключённые аккаунты идут первыми: владелец жаловался, что рабочие - // карточки разбросаны между пустыми слотами и их приходится выискивать. - // Пустые слоты ('не подключён') — всегда в конце группы. filtered.sort((a, b) => { const rank = (p) => { if (p.health_state === 'not_configured') return 5; @@ -613,7 +666,7 @@ function renderAccountsView() { container.innerHTML = html || '
Аккаунты по заданным фильтрам не найдены.
'; if (elements.accountsStatsSummary) { - elements.accountsStatsSummary.innerHTML = `Показано: ${visibleProfiles} из ${totalProfiles} аккаунтов`; + elements.accountsStatsSummary.innerHTML = `Показано: ${visibleProfiles} из ${totalProfiles} подключённых аккаунтов`; } container.querySelectorAll('.account-card').forEach((card) => { @@ -793,6 +846,9 @@ function renderOverviewView() { const diagramBox = document.getElementById('overview-route-diagram'); if (diagramBox) { const roles = currentSnapshot.routing || {}; + const allConnectedProfiles = Object.values(currentSnapshot.all_profiles || {}).filter( + (p) => isConnectedProfile(p) + ); let diagramHtml = ''; for (const [roleId, pipeline] of Object.entries(roles)) { @@ -808,6 +864,17 @@ function renderOverviewView() { const discoveredModels = (provSummary && provSummary.discovered_models && provSummary.discovered_models.length > 0) ? provSummary.discovered_models : []; const currentModel = node.model || (profile && profile.preferred_models && profile.preferred_models[0]) || ''; + const hasCurrentInConnected = allConnectedProfiles.some((p) => p.profile_id === node.profile_id); + let accountControlHtml = ''; + if (allConnectedProfiles.length > 0) { + accountControlHtml = ` + + `; + } + let modelControlHtml = ''; if (discoveredModels.length > 0) { modelControlHtml = ` @@ -832,6 +899,7 @@ function renderOverviewView() {
${escapeHtml(node.account_identity && node.account_identity !== 'Аккаунт не добавлен' ? node.account_identity : (node.display_name || node.profile_id))}
${escapeHtml(node.display_name || node.profile_id)} (${escapeHtml(node.provider)})
+ ${accountControlHtml} ${modelControlHtml} `; @@ -1401,6 +1469,8 @@ function populateSettingsForm(s) { const portInput = document.getElementById('setting-server-port'); const tokenBadge = document.getElementById('setting-token-status-badge'); const quotaSel = document.getElementById('setting-quota-interval'); + const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); + const quotaActionSel = document.getElementById('setting-quota-threshold-action'); const themeSel = document.getElementById('setting-theme'); const pathHome = document.getElementById('path-hermes-home'); @@ -1416,6 +1486,12 @@ function populateSettingsForm(s) { if (quotaSel && s.quota_refresh_interval_sec) { quotaSel.value = String(s.quota_refresh_interval_sec); } + if (quotaThresholdSel && s.quota_threshold_percent !== undefined) { + quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent)); + } + if (quotaActionSel && s.quota_threshold_action) { + quotaActionSel.value = s.quota_threshold_action; + } if (themeSel && s.theme) { themeSel.value = s.theme; applyTheme(s.theme); @@ -1445,12 +1521,16 @@ async function saveHubServerSettings() { const portInput = document.getElementById('setting-server-port'); const tokenInput = document.getElementById('setting-server-token-input'); const quotaSel = document.getElementById('setting-quota-interval'); + const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); + const quotaActionSel = document.getElementById('setting-quota-threshold-action'); const themeSel = document.getElementById('setting-theme'); const payload = { web_api_host: hostInput ? hostInput.value.trim() : '127.0.0.1', web_api_port: portInput ? parseInt(portInput.value, 10) || 5800 : 5800, quota_refresh_interval_sec: quotaSel ? parseInt(quotaSel.value, 10) || 300 : 300, + quota_threshold_percent: quotaThresholdSel ? parseFloat(quotaThresholdSel.value) || 10.0 : 10.0, + quota_threshold_action: quotaActionSel ? quotaActionSel.value : 'notify', theme: themeSel ? themeSel.value : 'system', }; @@ -1964,7 +2044,23 @@ async function handleNodeDrop(e, roleId, targetIndex) { } } -// ── Routing Node Model & Chain Management ── +// ── Routing Node Model, Account & Chain Management ── +async function handleNodeAccountChange(roleId, profileId, isPrimary = true) { + if (!roleId || !profileId) return; + showToast(`Назначение аккаунта '${profileId}' на роль '${roleId}'...`, 'info'); + const res = await executeAction('assign_role', { + role_id: roleId, + profile_id: profileId, + is_primary: isPrimary, + }); + if (res.ok) { + showToast(`Аккаунт '${profileId}' успешно назначен`, 'success'); + fetchSnapshot(); + } else { + showToast(res.message || 'Ошибка назначения аккаунта', 'error'); + } +} + async function handleNodeModelChange(roleId, profileId, newModel) { if (!newModel) return; showToast(`Сохранение модели '${newModel}' для ${profileId}...`, 'info'); diff --git a/src/antigravity_provider/router/web/static/index.html b/src/antigravity_provider/router/web/static/index.html index 20545b4..83d6215 100644 --- a/src/antigravity_provider/router/web/static/index.html +++ b/src/antigravity_provider/router/web/static/index.html @@ -111,9 +111,14 @@
-
-
Схема маршрутизации запросов
-
Распределение агентов и цепочки отказоустойчивости
+
+
+
Схема маршрутизации запросов
+
Распределение агентов и цепочки отказоустойчивости
+
+
@@ -329,6 +334,33 @@
+
+
+
Порог остатка квоты
+
Уровень остатка квоты аккаунта, при котором срабатывает действие
+
+
+ +
+
+
+
+
Действие при низком остатке квоты
+
Оповещение в журнале и готовности или автоматическое переключение на резервный аккаунт
+
+
+ +
+
Тема оформления
diff --git a/src/antigravity_provider/router/web/static/style.css b/src/antigravity_provider/router/web/static/style.css index 2d11513..b590e3f 100644 --- a/src/antigravity_provider/router/web/static/style.css +++ b/src/antigravity_provider/router/web/static/style.css @@ -449,6 +449,38 @@ body { color: var(--text-muted); } +.accounts-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + text-align: center; + background-color: var(--surface); + border: 1px dashed var(--border); + border-radius: var(--radius-md); + margin: 16px 0; +} + +.accounts-empty-state .empty-state-icon { + font-size: 40px; + margin-bottom: 12px; +} + +.accounts-empty-state h3 { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 6px; +} + +.accounts-empty-state p { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 18px; + max-width: 440px; +} + .accounts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); @@ -942,7 +974,8 @@ body { } .node-model-select, -.diagram-model-select { +.diagram-model-select, +.diagram-account-select { flex: 1; background-color: var(--bg-base); border: 1px solid var(--border-subtle); @@ -956,8 +989,17 @@ body { max-width: 100%; } +.diagram-account-select { + margin-bottom: 4px; + width: 100%; + font-family: var(--font-sans); + font-size: 11px; + font-weight: 500; +} + .node-model-select:focus, -.diagram-model-select:focus { +.diagram-model-select:focus, +.diagram-account-select:focus { border-color: var(--accent); } diff --git a/tests/test_accounts_distribution_and_thresholds_a26.py b/tests/test_accounts_distribution_and_thresholds_a26.py new file mode 100644 index 0000000..98a30ff --- /dev/null +++ b/tests/test_accounts_distribution_and_thresholds_a26.py @@ -0,0 +1,278 @@ +"""Comprehensive tests for A26: Unlimited Provider Accounts, Overview Role Assignment, +Smart Auto-Distribution, and Configurable Quota Thresholds. +""" +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any, Dict + +import pytest + +from antigravity_provider.router.action_handler import ActionExecutor, do_save_settings +from antigravity_provider.router.auto_assigner import AutoAssigner +from antigravity_provider.router.health_tracker import ( + HEALTHY, + QUOTA_EXHAUSTED, + HealthTracker, +) +from antigravity_provider.router.profile_manager import ProfileAuthManager +from antigravity_provider.router.quota_collector import AccountQuotaService, QuotaBucket, QuotaSnapshot +from antigravity_provider.router.router_config import ( + RolePolicy, + RouterConfig, + RouterProfileConfig, + load_router_config, + save_router_config, +) +from antigravity_provider.router.settings_service import ( + get_hub_settings, + invalidate_settings_cache, + save_hub_settings, +) +from antigravity_provider.router.state_store import HubStateStore +from antigravity_provider.router.unified_health import EventLogService, UnifiedHealthService + + +@pytest.fixture(autouse=True) +def setup_test_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Set up isolated HERMES_HOME and clean caches for each test.""" + hermes_home = tmp_path / "hermes" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("HERMES_ROUTER_CONFIG", str(hermes_home / "router_profiles.yaml")) + invalidate_settings_cache() + + # Reset singletons state where needed + AccountQuotaService.get()._snapshots.clear() + EventLogService.get()._events.clear() + + return hermes_home + + +@pytest.mark.unit +def test_unlimited_provider_slots_p0_1(tmp_path: Path): + """P0-1: Connecting 4th and 5th account of a provider removes slot ceiling and dynamically registers profiles.""" + # Pre-authenticate default 3 codex slots: codex-orch, codex-worker-1, codex-worker-2 + ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", {"api_key": "sk-1"}) + ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-1", {"api_key": "sk-2"}) + ProfileAuthManager.save_profile_auth("openai-codex", "codex-worker-2", {"api_key": "sk-3"}) + + # 4th slot should be generated dynamically (e.g. codex-4) + slot_4 = AutoAssigner.find_free_slot("openai-codex") + assert slot_4 == "codex-4" + + # Profile should now be ensured in router config + cfg = load_router_config() + assert "codex-4" in cfg.profiles + assert cfg.profiles["codex-4"].provider == "openai-codex" + + # Save auth for 4th slot, then 5th slot should be codex-5 + ProfileAuthManager.save_profile_auth("openai-codex", "codex-4", {"api_key": "sk-4"}) + slot_5 = AutoAssigner.find_free_slot("openai-codex") + assert slot_5 == "codex-5" + + # Test OpenCode provider dynamic slots: opengo-1, 2, 3 authenticated -> opengo-4 + ProfileAuthManager.save_profile_auth("opencode-go", "opengo-1", {"api_key": "op-1"}) + ProfileAuthManager.save_profile_auth("opencode-go", "opengo-2", {"api_key": "op-2"}) + ProfileAuthManager.save_profile_auth("opencode-go", "opengo-3", {"api_key": "op-3"}) + + slot_op_4 = AutoAssigner.find_free_slot("opencode-go") + assert slot_op_4 == "opengo-4" + + cfg = load_router_config() + assert "opengo-4" in cfg.profiles + + +@pytest.mark.unit +def test_single_account_assigned_to_all_six_roles_p0_3(): + """P0-3: A single account can be assigned as primary across all 6 canonical roles without error.""" + ProfileAuthManager.save_profile_auth("antigravity", "ag-w1", {"tokens": {"access_token": "token-1"}}) + + canonical_roles = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"] + + for rname in canonical_roles: + ok, msg = AutoAssigner.assign_profile_to_role("ag-w1", rname, is_primary=True) + assert ok is True, f"Failed assigning to role {rname}: {msg}" + + cfg = load_router_config() + for rname in canonical_roles: + assert rname in cfg.roles + assert cfg.roles[rname].preferred_chain[0] == "ag-w1" + + # Verify action executor handles assign_role cleanly + res = ActionExecutor.execute("assign_role", {"role_id": "orchestrator", "profile_id": "ag-w1", "is_primary": True}) + assert res.get("ok") is True + + +@pytest.mark.unit +def test_auto_assign_all_single_account_p0_4(): + """P0-4 (Scenario A): Exactly 1 connected account is assigned as primary to all 6 roles.""" + ProfileAuthManager.save_profile_auth("antigravity", "ag-w1", {"tokens": {"access_token": "token-1"}}) + + result = AutoAssigner.auto_assign_all() + assert result["success"] is True + assert result["total_authenticated"] == 1 + + cfg = load_router_config() + canonical_roles = ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"] + for rname in canonical_roles: + assert cfg.roles[rname].preferred_chain == ["ag-w1"] + + +@pytest.mark.unit +def test_auto_assign_all_two_accounts_same_provider_p0_4(): + """P0-4 (Scenario B): 2 accounts of the same provider are rotated across 6 roles for quota balance.""" + ProfileAuthManager.save_profile_auth("antigravity", "ag-w1", {"tokens": {"access_token": "token-1"}}) + ProfileAuthManager.save_profile_auth("antigravity", "ag-w2", {"tokens": {"access_token": "token-2"}}) + + result = AutoAssigner.auto_assign_all() + assert result["success"] is True + assert result["total_authenticated"] == 2 + + cfg = load_router_config() + # Primary accounts should alternate between ag-w1 and ag-w2 + primaries = [cfg.roles[r].preferred_chain[0] for r in ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]] + assert "ag-w1" in primaries + assert "ag-w2" in primaries + # Fallback chains should contain the alternate account + for r in ["orchestrator", "coder-primary", "coder-secondary", "reviewer", "research", "fast"]: + chain = cfg.roles[r].preferred_chain + assert len(chain) == 2 + assert set(chain) == {"ag-w1", "ag-w2"} + + +@pytest.mark.unit +def test_auto_assign_all_multi_provider_preferences_p0_4(): + """P0-4 (Scenario C): Multiple providers are distributed according to canonical role preferences.""" + ProfileAuthManager.save_profile_auth("openai-codex", "codex-orch", {"api_key": "sk-codex"}) + ProfileAuthManager.save_profile_auth("antigravity", "ag-w1", {"tokens": {"access_token": "token-ag"}}) + ProfileAuthManager.save_profile_auth("opencode-go", "opengo-1", {"api_key": "sk-opencode"}) + + result = AutoAssigner.auto_assign_all() + assert result["success"] is True + assert result["total_authenticated"] == 3 + + cfg = load_router_config() + # Orchestrator prefers codex + assert cfg.roles["orchestrator"].preferred_chain[0] == "codex-orch" + # Research / fast prefers opencode + assert cfg.roles["research"].preferred_chain[0] == "opengo-1" + assert cfg.roles["fast"].preferred_chain[0] == "opengo-1" + + +@pytest.mark.unit +def test_quota_threshold_notify_action_p0_5(tmp_path: Path): + """P0-5: When remaining <= threshold and action is 'notify', warnings are logged and profile remains healthy.""" + save_hub_settings({ + "quota_threshold_percent": 10.0, + "quota_threshold_action": "notify", + }) + + health_file = tmp_path / "hermes" / "router_state.json" + tracker = HealthTracker(state_file=health_file) + + # Measured quota is 8% (below 10% threshold) + tracker.reconcile_measured_quota("codex-orch", {"gpt": 8.0}) + + # Action is notify: profile should NOT be quota-exhausted + assert tracker.is_healthy("codex-orch", model_name="gpt-4o") is True + + # Event log should contain warning + events = EventLogService.get().get_events(limit=10) + warning_events = [e for e in events if e.level == "warning" and "порог" in e.message.lower()] + assert len(warning_events) >= 1 + + +@pytest.mark.unit +def test_quota_threshold_switch_action_and_auto_recovery_p0_5(tmp_path: Path): + """P0-5: When remaining <= threshold and action is 'switch', transitions to QUOTA_EXHAUSTED, and recovers automatically when quota restores.""" + save_hub_settings({ + "quota_threshold_percent": 10.0, + "quota_threshold_action": "switch", + }) + + health_file = tmp_path / "hermes" / "router_state.json" + tracker = HealthTracker(state_file=health_file) + + # 1. Low quota (5% <= 10%) -> switch to exhausted + tracker.reconcile_measured_quota("codex-orch", {"gpt": 5.0}) + assert tracker.is_healthy("codex-orch", model_name="gpt-4o") is False + rec = tracker.get_or_create("codex-orch") + assert rec.overall_state == QUOTA_EXHAUSTED or rec.families.get("gpt", {}).state == QUOTA_EXHAUSTED + + # 2. Quota recovers (80% > 10%) -> automatic recovery without restart + tracker.reconcile_measured_quota("codex-orch", {"gpt": 80.0}) + assert tracker.is_healthy("codex-orch", model_name="gpt-4o") is True + rec = tracker.get_or_create("codex-orch") + assert rec.overall_state == HEALTHY + assert rec.families.get("gpt", {}).state == HEALTHY + + +@pytest.mark.unit +def test_quota_threshold_zero_fake_p0_5(tmp_path: Path): + """P0-5: If quota is unknown (None / N/A), threshold does NOT trigger.""" + save_hub_settings({ + "quota_threshold_percent": 10.0, + "quota_threshold_action": "switch", + }) + + health_file = tmp_path / "hermes" / "router_state.json" + tracker = HealthTracker(state_file=health_file) + + # Reconcile empty/None measurements + tracker.reconcile_measured_quota("codex-orch", {}) + assert tracker.is_healthy("codex-orch", model_name="gpt-4o") is True + + +@pytest.mark.unit +def test_instant_settings_and_state_cache_update_p0_5(): + """P0-5: Saving settings updates cache and state store instantly without restart.""" + res, msg = do_save_settings({ + "quota_threshold_percent": 15.0, + "quota_threshold_action": "switch", + "monitoring_interval_seconds": 45, + }) + assert res is True + + settings = get_hub_settings() + assert settings["quota_threshold_percent"] == 15.0 + assert settings["quota_threshold_action"] == "switch" + assert settings["monitoring_interval_seconds"] == 45 + + # Check router config sync + cfg = load_router_config() + assert cfg.quota_threshold_percent == 15.0 + assert cfg.quota_threshold_action == "switch" + + +@pytest.mark.unit +def test_web_static_contracts_and_empty_state_p0_2_p0_3_p0_5(): + """Verify web client contract in app.js, style.css and index.html.""" + app_js_path = Path("src/antigravity_provider/router/web/static/app.js") + style_css_path = Path("src/antigravity_provider/router/web/static/style.css") + index_html_path = Path("src/antigravity_provider/router/web/static/index.html") + + assert app_js_path.exists() + assert style_css_path.exists() + assert index_html_path.exists() + + app_js = app_js_path.read_text(encoding="utf-8") + style_css = style_css_path.read_text(encoding="utf-8") + index_html = index_html_path.read_text(encoding="utf-8") + + # P0-2: Accounts empty state and connected-only filter + assert "accounts-empty-state" in app_js + assert "accounts-empty-state" in style_css + assert "Нет подключённых аккаунтов" in app_js + + # P0-3: Overview diagram account selector + assert "diagram-account-select" in app_js + assert "diagram-account-select" in style_css + assert "handleNodeAccountChange" in app_js + + # P0-5: Quota threshold settings + assert "setting-quota-threshold-percent" in index_html + assert "setting-quota-threshold-action" in index_html + assert "quota_threshold_percent" in app_js + assert "quota_threshold_action" in app_js diff --git a/tests/test_deployment_doctor.py b/tests/test_deployment_doctor.py index e3c9278..8e99296 100644 --- a/tests/test_deployment_doctor.py +++ b/tests/test_deployment_doctor.py @@ -82,21 +82,24 @@ def test_auto_assigner_find_free_slot_for_all_five_providers(clean_env): assert slot in config.profiles assert config.profiles[slot].provider == provider - # Test recommendation when all slots are filled + # Test allocation when all default slots are filled (A26: unlimited accounts) slot_claude = AutoAssigner.find_free_slot("claude") assert slot_claude is not None assert slot_claude in ("claude-orch", "claude-worker-1", "claude-worker-2") - # Simulate fake auth on all claude slots + # Simulate fake auth on all 3 default claude slots for c_slot in ["claude-orch", "claude-worker-1", "claude-worker-2"]: ProfileAuthManager.save_profile_auth("claude", c_slot, {"api_key": "sk-ant-test-key-1234567890123456"}) - # Now claude has no free slots -> find_free_slot must return None, NOT a non-existent candidate! - assert AutoAssigner.find_free_slot("claude") is None + # Now all default claude slots are taken -> find_free_slot dynamically generates claude-worker-3 + next_slot = AutoAssigner.find_free_slot("claude") + assert next_slot == "claude-worker-3" + cfg = load_router_config() + assert "claude-worker-3" in cfg.profiles + assert cfg.profiles["claude-worker-3"].provider == "claude" rec_slot, title, reason = AutoAssigner.recommend_assignment("claude") - assert rec_slot == "" - assert "Нет свободных слотов" in title + assert rec_slot == "claude-worker-3" @pytest.mark.unit