fix(installer): установка на Windows падала с кодом 12 из-за устаревшей проверки

Владелец получил «Ошибка установки (Код: 12)». Код 12 — провал скрипта
scripts/verify_multi_provider_router.py, который виндовый установщик запускает
после развёртывания. На Linux он не запускается, поэтому там всё вставало.

Скрипт пережил три изменения продукта и не был под них обновлён:

1. Требовал роль "orchestrator". A28 переименовал её в "manager", и проверка
   падала на первом же шаге. Теперь актуальное имя спрашивается у реестра
   ролей, а не помнится в скрипте.

2. Требовал непустую цепочку у КАЖДОЙ роли. A28 добавил роли, объявленные без
   реализации — guardian и cost-controller, — у них аккаунтов ещё нет.
   Установка падала из-за роли, которой никто не пользуется. Теперь пустая
   цепочка допустима и лишь отмечается, а обязательна она только у
   оркестрирующей роли: без неё маршрутизация действительно не работает.

3. Зашивал порядок цепочки codex -> antigravity -> opengo-3 и конкретные
   идентификаторы профилей. Но порядок — выбор владельца, он меняет его мышью,
   и любая перестановка роняла установку. Проверка переписана на механизм:
   берётся настоящая цепочка, роняются все профили кроме последнего
   достижимого, и проверяется, что маршрутизатор дошёл именно до него.
   Учтён предел max_failover_attempts — за него цепочка не проходится.

Карта адаптеров дополнена claude, grok и local: раньше в ней были только три
провайдера, и хвост цепочки из остальных не покрывался.

Проверено на конфигурации владельца: 10/10 CHECKS PASSED, код возврата 0.
486 passed, ruff чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-31 02:03:42 +07:00
parent d4c4facc94
commit 380c218547

View file

@ -6,6 +6,7 @@ import os
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
from contextlib import ExitStack
from unittest.mock import patch from unittest.mock import patch
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
@ -30,6 +31,9 @@ from antigravity_provider.router.router_engine import RouterEngine, get_router_e
from antigravity_provider.router.adapters.antigravity_adapter import AntigravityAdapter, get_profile_env_dir from antigravity_provider.router.adapters.antigravity_adapter import AntigravityAdapter, get_profile_env_dir
from antigravity_provider.router.adapters.codex_adapter import CodexAdapter from antigravity_provider.router.adapters.codex_adapter import CodexAdapter
from antigravity_provider.router.adapters.opencode_adapter import OpenCodeGoAdapter from antigravity_provider.router.adapters.opencode_adapter import OpenCodeGoAdapter
from antigravity_provider.router.adapters.claude_adapter import ClaudeAdapter
from antigravity_provider.router.adapters.grok_adapter import GrokAdapter
from antigravity_provider.router.adapters.local_adapter import LocalLLMAdapter
def run_checks() -> int: def run_checks() -> int:
@ -61,17 +65,43 @@ def run_checks() -> int:
# 2. Role Fallback Chains # 2. Role Fallback Chains
print("2. Checking role fallback policies...") print("2. Checking role fallback policies...")
assert "orchestrator" in config.roles, "Роль orchestrator отсутствует" # Имя оркестрирующей роли меняется вместе с реестром: в A28 orchestrator
# стал manager. Дословная проверка старого имени пережила миграцию и
# роняла установку на Windows с кодом 12 — на Linux этот скрипт не
# запускается, поэтому там всё ставилось. Спрашиваем актуальное имя у
# реестра, а не помним его в скрипте.
assert config.roles, "В конфигурации нет ни одной роли"
try:
from antigravity_provider.router.role_registry import RoleRegistry
orchestrating_role = RoleRegistry.resolve_canonical_role("orchestrator")
except Exception:
orchestrating_role = "orchestrator"
assert orchestrating_role in config.roles, (
f"Оркестрирующая роль {orchestrating_role!r} отсутствует; есть: {sorted(config.roles)}"
)
# Цепочки настраиваются владельцем и меняются — дословно их сверять нельзя. # Цепочки настраиваются владельцем и меняются — дословно их сверять нельзя.
# Проверяем то, что действительно ломает маршрутизацию: цепочка непуста и # Проверяем то, что действительно ломает маршрутизацию: цепочка непуста и
# каждый профиль в ней существует. # каждый профиль в ней существует.
# Пустая цепочка — не поломка сама по себе. В A28 появились роли,
# объявленные без реализации (guardian, cost-controller): аккаунтов у них
# ещё нет, и требовать цепочку — значит ронять установку из-за роли,
# которой никто не пользуется. Ломает маршрутизацию другое: ссылка на
# несуществующий профиль и пустая цепочка у ОРКЕСТРИРУЮЩЕЙ роли.
empty_chains = []
for role_name, policy in config.roles.items(): for role_name, policy in config.roles.items():
chain = policy.preferred_chain or [] chain = policy.preferred_chain or []
assert chain, f"У роли {role_name} пустая цепочка отказоустойчивости" if not chain:
empty_chains.append(role_name)
for pid in chain: for pid in chain:
assert pid in config.profiles, ( assert pid in config.profiles, (
f"Роль {role_name} ссылается на несуществующий профиль {pid}" f"Роль {role_name} ссылается на несуществующий профиль {pid}"
) )
assert config.roles[orchestrating_role].preferred_chain, (
f"У оркестрирующей роли {orchestrating_role!r} пустая цепочка — маршрутизация работать не будет"
)
if empty_chains:
print(f" [INFO] Без аккаунтов пока: {', '.join(sorted(empty_chains))}")
print(f" [PASS] Цепочки {len(config.roles)} ролей ссылаются только на существующие профили") print(f" [PASS] Цепочки {len(config.roles)} ролей ссылаются только на существующие профили")
passed += 1 passed += 1
@ -151,26 +181,61 @@ def run_checks() -> int:
engine = RouterEngine(config=config) engine = RouterEngine(config=config)
engine.health.clear_cooldown() engine.health.clear_cooldown()
mock_codex = {"id": "c1", "choices": [{"message": {"role": "assistant", "content": "from-codex"}}]} # Проверяется МЕХАНИЗМ отказоустойчивости, а не расстановка аккаунтов.
mock_ag = {"id": "a1", "choices": [{"message": {"role": "assistant", "content": "from-antigravity"}}]} #
mock_opengo = {"id": "o1", "choices": [{"message": {"role": "assistant", "content": "from-opencode"}}]} # Прежняя версия зашивала порядок codex -> antigravity -> opengo-3 и
# конкретные идентификаторы профилей. Но порядок в цепочке — это выбор
# владельца, он его меняет мышью в интерфейсе. Любая перестановка роняла
# проверку, а с ней и установку на Windows с кодом 12.
#
# Здесь: берём настоящую цепочку оркестрирующей роли, роняем все профили
# кроме последнего и убеждаемся, что маршрутизатор дошёл именно до него.
# Учитываем предел попыток: если цепочка длиннее, до её хвоста
# маршрутизатор просто не дойдёт, и ожидать этого нельзя.
full_chain = list(config.roles[orchestrating_role].preferred_chain)
max_attempts = getattr(config.roles[orchestrating_role], "max_failover_attempts", 0) or len(full_chain)
chain = full_chain[:max_attempts]
assert len(chain) >= 2, (
f"В цепочке роли {orchestrating_role!r} меньше двух профилей — "
"отказоустойчивость проверить нечем"
)
last_pid = chain[-1]
last_provider = config.profiles[last_pid].provider
# Simulate codex failure -> route to Antigravity fallback adapter_by_provider = {
with patch.object(CodexAdapter, "invoke", side_effect=RuntimeError("Insufficient quota")): "openai-codex": CodexAdapter,
with patch.object(AntigravityAdapter, "invoke", return_value=mock_ag): "antigravity": AntigravityAdapter,
res = engine.route_request({"messages": [{"role": "user", "content": "test"}]}, role="orchestrator", session_id="s1") "opencode-go": OpenCodeGoAdapter,
assert res["choices"][0]["message"]["content"] == "from-antigravity" "claude": ClaudeAdapter,
assert res["router_metadata"]["profile_id"] == "ag-orch-fallback" "grok": GrokAdapter,
"local": LocalLLMAdapter,
}
# Simulate both codex and ag failure -> route to OpenCode Go expected = {"id": "ok", "choices": [{"message": {"role": "assistant", "content": "from-last-in-chain"}}]}
with patch.object(CodexAdapter, "invoke", side_effect=RuntimeError("Insufficient quota")): failing = {cls for pid in chain[:-1]
with patch.object(AntigravityAdapter, "invoke", side_effect=RuntimeError("Individual quota reached")): if (cls := adapter_by_provider.get(config.profiles[pid].provider)) is not None}
with patch.object(OpenCodeGoAdapter, "invoke", return_value=mock_opengo): winner = adapter_by_provider.get(last_provider)
res2 = engine.route_request({"messages": [{"role": "user", "content": "test2"}]}, role="orchestrator", session_id="s2")
assert res2["choices"][0]["message"]["content"] == "from-opencode"
assert res2["router_metadata"]["profile_id"] == "opengo-3"
print(" [PASS] 3-tier role failover chain (Codex -> Antigravity -> OpenCode Go) verified") if winner is None or winner in failing:
print(f" [SKIP] Последний профиль цепочки ({last_pid}, {last_provider}) "
"не покрыт заглушками адаптеров")
else:
with ExitStack() as stack:
for cls in failing:
stack.enter_context(patch.object(cls, "invoke", side_effect=RuntimeError("Insufficient quota")))
stack.enter_context(patch.object(winner, "invoke", return_value=expected))
res = engine.route_request(
{"messages": [{"role": "user", "content": "test"}]},
role=orchestrating_role,
session_id="verify-failover",
)
assert res["choices"][0]["message"]["content"] == "from-last-in-chain", (
f"Отказоустойчивость не дошла до последнего профиля цепочки: {res}"
)
assert res["router_metadata"]["profile_id"] == last_pid, (
f"Ожидался профиль {last_pid}, получен {res['router_metadata']['profile_id']}"
)
print(f" [PASS] Отказоустойчивость прошла цепочку {' -> '.join(chain)}")
passed += 1 passed += 1
# 10. Passthrough & Graceful fallback # 10. Passthrough & Graceful fallback