From 7e83c383405ff1b4fd2e5652c62d0f5740e5bdc0 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Sun, 30 Aug 2026 20:04:52 +0700 Subject: [PATCH] =?UTF-8?q?perf(antigravity):=20=D0=B8=D0=B7=20=D0=B4?= =?UTF-8?q?=D0=B5=D1=81=D1=8F=D1=82=D0=B8=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=B2=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD=D0=BE=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=20=D0=BE=D0=B4=D0=B8=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В adapters/antigravity_adapter.py жил модульный _AGY_INVOCATION_LOCK, общий для ВСЕХ профилей Antigravity. Он брался при каждом вызове, у которого есть учётные данные, то есть при каждом рабочем. Ветка без мьютекса срабатывала только у профиля без учётки — у вызова, который и так упадёт. Измерено на живом адаптере: три параллельных вызова по одной секунде занимали 3.01 с. При десяти подключённых аккаунтах одновременно работал ровно один, чем обесценивалась вся мультиаккаунтность — то, ради чего хаб и делался. Мьютекс охранял пустоту. Он был введён в 50fde5f со словами «guarded global gemini:antigravity credential swap ... to eliminate concurrent subprocess race», когда подмена учётных данных была ГЛОБАЛЬНОЙ. С тех пор она стала попрофильной: agy_subprocess пишет в profile_dir/.gemini/oauth_creds.json, а HOME, USERPROFILE, HOMEPATH и HOMEDRIVE подменяются на каталог профиля. Общего состояния между профилями не осталось — проверено поиском глобальных путей и обращений к keyring, их нет. После снятия: те же три вызова занимают 1.00 с, и каждый идёт со своим HOME (ag-w1, ag-w2, ag-w3) — изоляция не пострадала. Ограничение одновременности остаётся за LeaseManager: он считает лизы по профилю и настраивается через max_concurrency, в том числе значением 1 для локальных моделей с --parallel 1. Добавлен тест, удерживающий это свойство: он падает, если вызовы разных профилей снова начнут сериализоваться. Существующий тест изоляции учётных данных проходит без изменений. Найдено при разборе анализа, который Antigravity провёл на сервере владельца (hermes-muliacount). 459 passed, ruff чисто. Co-Authored-By: Claude Opus 5 --- .../router/adapters/antigravity_adapter.py | 20 +++++-- tests/test_antigravity_concurrency.py | 59 +++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/antigravity_provider/router/adapters/antigravity_adapter.py b/src/antigravity_provider/router/adapters/antigravity_adapter.py index 3bf94ff..c2b8951 100644 --- a/src/antigravity_provider/router/adapters/antigravity_adapter.py +++ b/src/antigravity_provider/router/adapters/antigravity_adapter.py @@ -27,9 +27,20 @@ from ..router_config import RouterProfileConfig from .base_adapter import BaseProviderAdapter, ErrorCategory, ErrorClassification -import threading - -_AGY_INVOCATION_LOCK = threading.RLock() +# Глобального мьютекса здесь больше нет. +# +# Он был введён в 50fde5f, чтобы защитить ГЛОБАЛЬНУЮ подмену учётных данных +# gemini:antigravity от гонки между подпроцессами. С тех пор подмена перестала +# быть глобальной: agy_subprocess пишет в profile_dir/.gemini/oauth_creds.json, +# а HOME, USERPROFILE, HOMEPATH и HOMEDRIVE подменяются на каталог профиля. +# Общего состояния между профилями не осталось, и мьютекс охранял пустоту. +# +# Стоил он при этом дорого: измерено, что три параллельных вызова по одной +# секунде занимали 3.01 с. То есть из десяти аккаунтов Antigravity +# одновременно работал ровно один, чем обесценивалась вся мультиаккаунтность. +# +# Ограничение одновременности остаётся за LeaseManager, который считает лизы +# ПО ПРОФИЛЮ и настраивается через max_concurrency. def get_profile_env_dir(profile_id: str) -> Path: @@ -77,8 +88,7 @@ class AntigravityAdapter(BaseProviderAdapter): profile_id=profile.profile_id, ) - with _AGY_INVOCATION_LOCK: - res = agy_generate(req, custom_env=custom_env, profile_id=profile.profile_id) + res = agy_generate(req, custom_env=custom_env, profile_id=profile.profile_id) else: res = agy_generate(req, custom_env=custom_env, profile_id=profile.profile_id) diff --git a/tests/test_antigravity_concurrency.py b/tests/test_antigravity_concurrency.py index 78e9ba5..803fd76 100644 --- a/tests/test_antigravity_concurrency.py +++ b/tests/test_antigravity_concurrency.py @@ -77,3 +77,62 @@ def test_credential_restoration_on_subprocess_exception(tmp_path, monkeypatch): # Must be cleanly restored assert True + +def test_antigravity_profiles_run_in_parallel(): + """Вызовы разных профилей Antigravity идут одновременно, а не по очереди. + + В adapters/antigravity_adapter.py жил модульный _AGY_INVOCATION_LOCK. Он был + введён, когда подмена учётных данных gemini:antigravity была ГЛОБАЛЬНОЙ, и + защищал её от гонки. Позже подмена стала попрофильной — agy_subprocess + пишет в profile_dir/.gemini/oauth_creds.json, а HOME, USERPROFILE, HOMEPATH + и HOMEDRIVE подменяются на каталог профиля, — но мьютекс остался. + + Цена измерялась: три параллельных вызова по одной секунде занимали 3.01 с. + То есть из десяти подключённых аккаунтов одновременно работал ровно один. + + Тест удерживает это свойство: три вызова по 0.4 с должны уложиться заметно + быстрее суммы, и каждый обязан идти со своим изолированным HOME. + """ + import threading + import time + from unittest.mock import patch + + import antigravity_provider.router.adapters.antigravity_adapter as adapter_mod + + auth = {"token": {"access_token": "a", "refresh_token": "r", "expires_at": 9999999999}} + + class _Profile: + def __init__(self, pid): + self.profile_id = pid + self.preferred_models = ["gemini-3.7-flash"] + self.provider = "antigravity" + + homes = [] + + def _slow(req, custom_env=None, profile_id=None): + env = custom_env or {} + homes.append(env.get("HOME") or env.get("USERPROFILE")) + time.sleep(0.4) + return {"content": "ok"} + + with patch.object(adapter_mod, "agy_generate", side_effect=_slow), patch.object( + adapter_mod.ProfileAuthManager, "load_profile_auth", return_value=auth + ): + started = time.time() + threads = [ + threading.Thread(target=lambda pid=f"ag-w{i}": adapter_mod.AntigravityAdapter().invoke( + _Profile(pid), {"messages": []} + )) + for i in range(1, 4) + ] + for t in threads: + t.start() + for t in threads: + t.join() + elapsed = time.time() - started + + assert elapsed < 0.9, ( + f"вызовы разных профилей сериализуются ({elapsed:.2f} с при трёх по 0.4 с); " + "вероятно вернулся глобальный мьютекс" + ) + assert len({h for h in homes if h}) == 3, f"профили делят HOME: {homes}"