fix(accounts): отказ проверки переживал более свежий каталог моделей

В карточке владельца рядом стояли «Проверен: не работает — Please sign in» и
«Получено 11 моделей · 13:25:53». Источники разные: красная строка берётся из
состояния проверки, список — из кэша каталога, и обновляются они независимо.
Каталог был получен позже отказа, то есть провайдер с тех пор ответил, а
карточка продолжала утверждать обратное.

Теперь, если каталог получен без ошибки и позже неудачной проверки, вердикт
показывается как устаревший с предложением проверить заново. Объявлять аккаунт
рабочим не за что — проверка после этого не выполнялась, и выдумывать её
результат нельзя.

Отдельно установлено: поле commit в /api/health читается из файла на диске при
каждом запросе, поэтому устаревший процесс рапортует свежий коммит. Различать
сборки по нему нельзя.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-09-01 14:03:56 +07:00
parent 0ad946eccd
commit f188a18136
5 changed files with 138 additions and 2 deletions

View file

@ -18,7 +18,7 @@ namespace HermesHubSetup
// Подставляется сборщиком из фактического git-коммита. Раньше здесь
// жил зашитый "8cddc9f", то есть манифест сообщал неправду о том, из
// какого кода собран установщик.
public const string BuildCommit = "001cd1f";
public const string BuildCommit = "0ad946e";
public const string MIN_HERMES_VERSION = "0.20.0";
public const string MAX_TESTED_HERMES = "0.20.4";

Binary file not shown.

Binary file not shown.

View file

@ -543,7 +543,25 @@ class UnifiedHealthService:
if check.get("state") == "checking":
health_state, health_lbl = "checking", "Проверяется…"
elif check.get("state") == "failed":
health_state, health_lbl = STATUS_UNHEALTHY, "Проверен: не работает — " + check.get("message", "Причина Н")
# Вердикт проверки и каталог моделей обновляются
# независимо, и в карточке владельца они оказались
# рядом: «не работает — Please sign in» и тут же
# «Получено 11 моделей» с более поздним временем.
# Утверждать отказ, когда каталог получен ПОЗЖЕ него,
# нельзя: провайдер с тех пор ответил. Но и объявлять
# аккаунт рабочим не за что — проверка после этого не
# выполнялась. Честный ответ — что вердикт устарел.
checked_at = check.get("checked_at") or 0
catalog_at = model_meta.get("discovered_at") or 0
if not model_meta.get("error") and catalog_at > checked_at:
when = datetime.datetime.fromtimestamp(catalog_at).strftime("%H:%M:%S")
health_state = STATUS_NOT_TESTED
health_lbl = (
f"Проверка устарела: каталог моделей получен позже ({when}). "
"Нажмите «Проверить подключение»."
)
else:
health_state, health_lbl = STATUS_UNHEALTHY, "Проверен: не работает — " + check.get("message", "Причина Н")
elif check.get("state") == "working" and health_state in (STATUS_NOT_TESTED, STATUS_HEALTHY):
health_state, health_lbl = STATUS_HEALTHY, "Проверен: работает"
assigned = role_assignments.get(pid, [])

View file

@ -0,0 +1,118 @@
"""Вердикт проверки не должен переживать более свежий каталог моделей.
В карточке владельца рядом стояли «Проверен: не работает Please sign in» и
«Получено 11 моделей · 13:25:53». Источники разные и обновляются независимо:
красная строка берётся из состояния проверки, список из кэша каталога.
Каталог был получен ПОЗЖЕ отказа, то есть провайдер с тех пор ответил.
Утверждать отказ в такой ситуации нельзя. Объявлять аккаунт рабочим тоже
не за что: проверка после этого не выполнялась. Честный ответ один: вердикт
устарел, нужна новая проверка.
"""
from __future__ import annotations
import time
import pytest
from antigravity_provider.router.account_probe_service import AccountProbeService
from antigravity_provider.router.model_discovery_service import ModelDiscoveryService
from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.router_config import (
RouterConfig,
RouterProfileConfig,
save_router_config,
)
from antigravity_provider.router.unified_health import (
STATUS_NOT_TESTED,
STATUS_UNHEALTHY,
UnifiedHealthService,
)
def _prepare(failed_at: float, catalog_at: float | None, catalog_error=None):
cfg = RouterConfig()
cfg.profiles["ag-5"] = RouterProfileConfig(
profile_id="ag-5",
provider="antigravity",
account_id="ag-5",
preferred_models=[],
enabled=True,
)
save_router_config(cfg)
ProfileAuthManager.save_profile_auth(
"antigravity",
"ag-5",
{
# Запись входа Antigravity по праву отказывает без токена доступа,
# поэтому в проверке он должен быть настоящим по форме.
"token": {
"access_token": "ya29.TEST",
"refresh_token": "1//TEST",
"token_type": "Bearer",
"expiry_date": int((time.time() + 3600) * 1000),
},
"email": "owner@gmail.com",
"auth_method": "oauth",
},
)
probe = AccountProbeService.get()
with probe._lock:
probe._states["ag-5"] = {
"state": "failed",
"provider": "antigravity",
"checked_at": failed_at,
"message": "agy models: код 1. Please sign in to view available models.",
}
discovery = ModelDiscoveryService.get()
with discovery._cache_lock:
discovery._cache["antigravity:ag-5"] = {
"models": ["gemini-3.1-pro-high", "claude-sonnet-4-6"],
"discovered_at": catalog_at,
"error": catalog_error,
}
return UnifiedHealthService.get()
def _view(service):
profiles = service.scan_all(force=True)
return next(p for p in profiles["antigravity"] if p.profile_id == "ag-5")
@pytest.mark.unit
def test_newer_catalog_makes_the_failed_verdict_stale(tmp_path):
now = time.time()
view = _view(_prepare(failed_at=now - 60, catalog_at=now - 20))
assert view.health_state == STATUS_NOT_TESTED
assert "устарела" in view.health_label_ru
assert "Please sign in" not in view.health_label_ru
@pytest.mark.unit
def test_failure_stands_when_it_is_the_newer_fact(tmp_path):
now = time.time()
view = _view(_prepare(failed_at=now - 10, catalog_at=now - 300))
assert view.health_state == STATUS_UNHEALTHY
assert "Please sign in" in view.health_label_ru
@pytest.mark.unit
def test_failure_stands_when_catalog_itself_errored(tmp_path):
now = time.time()
view = _view(
_prepare(failed_at=now - 60, catalog_at=now - 20, catalog_error="Сервер отказал")
)
assert view.health_state == STATUS_UNHEALTHY
@pytest.mark.unit
def test_failure_stands_when_catalog_was_never_obtained(tmp_path):
now = time.time()
view = _view(_prepare(failed_at=now - 60, catalog_at=None))
assert view.health_state == STATUS_UNHEALTHY