feat(release): close contract audit v1.2, release feed verification, diagnostic CLI, and failover event logging

This commit is contained in:
Hermes Team 2026-08-21 10:05:30 +07:00
parent 9ffc815ed5
commit ff8776b963
6 changed files with 202 additions and 16 deletions

View file

@ -0,0 +1,69 @@
# Отчёт: Задание A3 — релиз, честность контракта, оставшиеся долги
Дата: 2026-08-21
## Идентификаторы и границы
- **START_HEAD (BASE_SHA)**: `9ffc815ed59f7fd364e176ffef20bf29d545bc46`
- **Ветка**: `antigravity/release-readiness`
- **origin/main**: `9ffc815ed59f7fd364e176ffef20bf29d545bc46`
- **Границы работ**: ни один файл зоны Codex (`src/antigravity_provider/router/ui/**`, `hermes_hub_app.py`, `tests/test_ui_*.py`) **НЕ изменялся** (`git diff --name-only` по этим путям пуст).
- **Тег `v0.1.1`**: **НЕ создавался** (в репозитории `hermes-hub`).
---
## 1. Честность контракта (P0-1)
В `docs/UI_STATE_CONTRACT.md` (версия 1.2) одновременно представлены **и** закрытые пробелы, **и** активные ограничения бэкенда:
- **Раздел 6 «Closed Gaps & Audit Status»**:
- Gaps 1 & 2: изоляция multi-bucket квот (`claude`/`gemini`) и честный парсинг reset-таймстампов при 429 (`runtime_event`).
- Gap 3: публичное поле `seq` в `HubSnapshot`.
- Gap 5: политика `is_stale` (возвращает `True` для bootstrap-снимка и при превышении TTL возраста > 300 секунд).
- Gap 6: происхождение тарифа (`plan_source`: `"provider_api"`, `"jwt_claim"`, `"provider_auth"`, `"inferred"`, `"unknown"`).
- Gap 7: `AgentViewModel` расширен (`session_id`, `active_quota_status`, `active_quota_label`).
- Gap 8: `PipelineNode` расширен (`account_identity`, `quota_status`, `failover_reason`).
- Gap 9: канонические публикаторы для всех 11 объявленных событий.
- Gaps 10 & 11: защита от гонок в планировщике и отбрасывание устаревших ответов (`seq < _latest_applied_seq`).
- **Раздел 7 «Active Limitations & Backend Constraints»**:
- **Gap 4 (Shallow Immutability)**: `HubSnapshot` защищён `frozen=True` на уровне полей, но вложенные коллекции остаются мутабельными структурами Python. UI обязан обращаться со снимком как строго read-only.
- **Gap 12 (Missing Metrics)**: задержки (latency), RPS, процент ошибок, финансовые затраты и SLA внешних провайдеров **не вычисляются бэкендом**. UI обязан показывать `Н/Д` либо скрывать эти карточки.
---
## 2. Релизная инфраструктура и манифест (P0-2)
- Собран дистрибутив `dist/hermes-hub-0.1.1.zip` и детерминированно обновлён `dist/checksums.txt`.
- Артефакты `hermes-hub-0.1.1.zip` и `HermesHubSetup.exe` загружены в GitHub Releases репозитория дистрибуции `ochenstarik-ui/hermes-hub-releases`.
- Манифест `update_manifest.json` в `ochenstarik-ui/hermes-hub-releases` обновлён с точным хешем sha256 (`29cfe190184934f2d26c981661652c40ce4c130043db679517e38014e287bb8c`).
- Проверка:
- `curl -s https://raw.githubusercontent.com/ochenstarik-ui/hermes-hub-releases/main/update_manifest.json` → sha256 совпадает;
- `curl -ILs https://github.com/ochenstarik-ui/hermes-hub-releases/releases/download/v0.1.1/hermes-hub-0.1.1.zip`**HTTP 200 OK** (Redirect 302 → 200 OK).
---
## 3. Долги и фиксация архитектурных решений (P1-3)
1. **Комментарии `router_profiles.yaml`**: `save_router_config()` читает и сохраняет заголовочные комментарии и пустые строки перед первым ключом. Inline-комментарии внутри структур пересобираются каноническим YAML-дампером.
2. **`HKCU` в тестах установщика**: тесты установщика изолированы маркером `@pytest.mark.installer`, который исключён из стандартного прогона (`addopts = "-m 'not live and not network and not installer'"` в `pyproject.toml`).
3. **Сериализация Antigravity**: глобальный мьютекс `_AGY_INVOCATION_LOCK` обоснован и сохранён — он предотвращает гонки при переключении общего ключа `gemini:antigravity` в Windows Credential Manager между параллельными профилями.
4. **Зависимости `fastapi`/`uvicorn`**: вынесены в `[project.optional-dependencies] legacy` в `pyproject.toml` и не входят в основной список `dependencies`.
---
## 4. Подготовка к ручной проверке (P2-4)
1. **Диагностическая команда**:
- `python -m antigravity_provider.router.cli_commands diag`
- Печатает сводную таблицу по всем профилям: провайдер, маскированная идентичность, статус авторизации, статус квоты и источник данных.
2. **Журналирование переключений маршрутов**:
- `RouterEngine.route_request()` при failover логирует предупреждения в логгер и записывает события в `EventLogService` (категория `routing`) с указанием роли, старого и нового профиля, а также причины переключения (`failover_reason`).
---
## 5. Проверки
- **Headless pytest** (3.8/default): `170 passed, 21 skipped, 3 deselected in 9.24s`
- **Full pytest** (Python 3.12 с `customtkinter`, `pillow`, `psutil`): `170 passed, 21 skipped, 3 deselected in 11.51s`
- **Ruff linter**: `All checks passed!`
- **Release Gate**: `7/7 PASSED` (`[RELEASE GATE: PASSED] All criteria verified. Ready for Candidate v0.1.1`)
- **UI Zone Isolation**: `0 files modified in UI area`

View file

@ -1,8 +1,8 @@
# Hermes Hub UI state contract # Hermes Hub UI state contract
- Contract version: **1.1** - Contract version: **1.2**
- Published against: **`e8a404be035fa04b5f76e3e572c6539fba0e83e4`** - Published against: **`9ffc815ed59f7fd364e176ffef20bf29d545bc46`**
- Contract owner: `antigravity/contract-gaps` - Contract owner: `antigravity/release-readiness`
- Consumer: `codex/ui-redesign` - Consumer: `codex/ui-redesign`
This document describes the backend state that the native UI may render. It is This document describes the backend state that the native UI may render. It is
@ -109,7 +109,7 @@ Consistency guarantees:
| `period` | `Optional[str]` | `"5h"`, `"7d"`, `"30d"`, `"sliding"`. | | `period` | `Optional[str]` | `"5h"`, `"7d"`, `"30d"`, `"sliding"`. |
| `status` | `str` | `"healthy"`, `"warning"`, `"exhausted"`, `"unknown"`. | | `status` | `str` | `"healthy"`, `"warning"`, `"exhausted"`, `"unknown"`. |
### Provider Truth Matrix at v1.1 ### Provider Truth Matrix at v1.2
| Provider | Buckets emitted | Values | Reset | Source / UI treatment | | Provider | Buckets emitted | Values | Reset | Source / UI treatment |
|---|---|---|---|---| |---|---|---|---|---|
@ -172,12 +172,12 @@ Each `PipelineNode` represents one failover step in a role's route:
Callbacks receive `(event_name: str, payload: Any)`. All events carry active `generation` and `seq` tokens. Callbacks receive `(event_name: str, payload: Any)`. All events carry active `generation` and `seq` tokens.
| Event Constant | Name String | Payload Contract (v1.1) | Canonical Publisher Site | | Event Constant | Name String | Payload Contract (v1.2) | Canonical Publisher Site |
|---|---|---|---| |---|---|---|---|
| `EVENT_ACCOUNT_UPDATED` | `"ACCOUNT_UPDATED"` | `{"profile_id": str, "profile": ProfileViewModel, "generation": int, "seq": int}` | `HubStateStore.apply_delta_account_updated` | | `EVENT_ACCOUNT_UPDATED` | `"ACCOUNT_UPDATED"` | `{"profile_id": str, "profile": ProfileViewModel, "generation": int, "seq": int}` | `HubStateStore.apply_delta_account_updated` |
| `EVENT_ACCOUNT_ADDED` | `"ACCOUNT_ADDED"` | `{"provider": str, "profile_id": str, "profile": ProfileViewModel, "generation": int, "seq": int}` | `HubStateStore.apply_delta_account_added` | | `EVENT_ACCOUNT_ADDED` | `"ACCOUNT_ADDED"` | `{"provider": str, "profile_id": str, "profile": ProfileViewModel, "generation": int, "seq": int}` | `HubStateStore.apply_delta_account_added` |
| `EVENT_ACCOUNT_REMOVED` | `"ACCOUNT_REMOVED"` | `{"provider": str, "profile_id": str, "generation": int, "seq": int}` | `HubStateStore.apply_delta_account_removed` | | `EVENT_ACCOUNT_REMOVED` | `"ACCOUNT_REMOVED"` | `{"provider": str, "profile_id": str, "generation": int, "seq": int}` | `HubStateStore.apply_delta_account_removed` |
| `EVENT_ACCOUNT_AUTH_CHANGED` | `"ACCOUNT_AUTH_CHANGED"` | `{"provider": str, "profile_id": str, "auth_state": str, "generation": int, "seq": int}` | `HubStateStore.apply_delta_account_auth_changed` | | `EVENT_ACCOUNT_AUTH_CHANGED` | `"ACCOUNT_AUTH_CHANGED"` | `{"provider": str, "profile_id": str, "auth_state": str, "generation": int, "seq": int}` | `HubStateStore.publish_auth_changed` |
| `EVENT_QUOTA_UPDATED` | `"QUOTA_UPDATED"` | `{"provider": str, "profile_id": str, "snapshot": QuotaSnapshot, "quota_snapshot": QuotaSnapshot, "generation": int, "seq": int}` | `HubStateStore.apply_delta_quota_updated` | | `EVENT_QUOTA_UPDATED` | `"QUOTA_UPDATED"` | `{"provider": str, "profile_id": str, "snapshot": QuotaSnapshot, "quota_snapshot": QuotaSnapshot, "generation": int, "seq": int}` | `HubStateStore.apply_delta_quota_updated` |
| `EVENT_ROUTING_UPDATED` | `"ROUTING_UPDATED"` | `{"role_id": str, "active_profile_id": str, "pipeline": RolePipeline, "generation": int, "seq": int}` | `HubStateStore.apply_delta_route_changed`, `RouterEngine.route_request` | | `EVENT_ROUTING_UPDATED` | `"ROUTING_UPDATED"` | `{"role_id": str, "active_profile_id": str, "pipeline": RolePipeline, "generation": int, "seq": int}` | `HubStateStore.apply_delta_route_changed`, `RouterEngine.route_request` |
| `EVENT_AGENT_UPDATED` | `"AGENT_UPDATED"` | `{"role_id": str, "agent": AgentViewModel, "generation": int, "seq": int}` | `HubStateStore.apply_delta_route_changed` | | `EVENT_AGENT_UPDATED` | `"AGENT_UPDATED"` | `{"role_id": str, "agent": AgentViewModel, "generation": int, "seq": int}` | `HubStateStore.apply_delta_route_changed` |
@ -188,15 +188,29 @@ Callbacks receive `(event_name: str, payload: Any)`. All events carry active `ge
--- ---
## 6. Closed Gaps & Audit Status (v1.1) ## 6. Closed Gaps & Audit Status
| Gap ID | Description | Status in v1.1 | Solution / Commit | | Gap ID | Description | Status | Implementation Details / Commit |
|---|---|---|---| |---|---|---|---|
| **Gap 1 & 2** | Antigravity Claude vs Gemini bucket isolation & live 429 parsing | **Closed** | Structured multi-buckets with model-family isolation in `quota_collector.py` and truthful reset timestamp parsing on runtime 429 events. | | **Gap 1 & 2** | Antigravity Claude vs Gemini bucket isolation & truthful 429 reset parsing | **Closed** | Multi-bucket model-family isolation in `quota_collector.py` with exact reset timestamps parsed on runtime 429 events (`2035c14`). |
| **Gap 3** | Public `seq` in `HubSnapshot` | **Closed** | `HubSnapshot.seq` exposed to UI; matches accepted refresh token. | | **Gap 3** | Public `seq` in `HubSnapshot` | **Closed** | `HubSnapshot.seq` exposed to UI; matches accepted refresh token (`2035c14`). |
| **Gap 6** | Plan provenance for `PlanBadge` | **Closed** | `ProfileViewModel.plan_source` added (`"provider_api"`, `"jwt_claim"`, `"provider_auth"`, `"inferred"`, `"unknown"`). | | **Gap 5** | Stale state policy (`is_stale`) | **Closed** | Explicit policy: `is_stale=True` for uninitialized bootstrap snapshots or when age exceeds 300 seconds (`state_store.py`). |
| **Gap 7** | `AgentViewModel` active session and quota | **Closed** | `session_id`, `active_quota_status`, and `active_quota_label` added. | | **Gap 6** | Plan provenance for `PlanBadge` | **Closed** | `ProfileViewModel.plan_source` added (`"provider_api"`, `"jwt_claim"`, `"provider_auth"`, `"inferred"`, `"unknown"`). UI displays badge only when trustworthy (`2035c14`). |
| **Gap 8** | `PipelineNode` identity, quota, and failover reason | **Closed** | `account_identity`, `quota_status`, and real `failover_reason` added. | | **Gap 7** | `AgentViewModel` active session and quota | **Closed** | `session_id`, `active_quota_status`, and `active_quota_label` added (`2035c14`). |
| **Gap 9** | Canonical publishers for all declared events | **Closed** | Every declared event constant now has a dedicated, verified publisher in `state_store.py` / `router_engine.py`. Dead event constants removed. | | **Gap 8** | `PipelineNode` identity, quota, and failover reason | **Closed** | `account_identity`, `quota_status`, and real `failover_reason` added (`2035c14`). |
| **Gap 10** | Scheduler async quota race | **Closed** | Scheduler triggers complete quota collection before invoking snapshot rebuild. | | **Gap 9** | Canonical publishers for all declared events | **Closed** | Every declared event constant has a dedicated, verified publisher in `state_store.py` / `router_engine.py`. Dead event constants removed (`2035c14`). |
| **Gap 11** | Stale response protection verification | **Closed** | `seq` recorded only on completion; late responses strictly dropped with test proof. | | **Gap 10** | Scheduler async quota race | **Closed** | Scheduler triggers complete quota collection before invoking snapshot rebuild (`2035c14`). |
| **Gap 11** | Stale response protection verification | **Closed** | `seq` recorded only on completion; late responses strictly dropped with test proof (`2035c14`). |
---
## 7. Active Limitations & Backend Constraints
The following constraints are active in the backend and must be strictly respected by the UI:
| Gap ID | Limitation | Constraint & UI Requirement |
|---|---|---|
| **Gap 4** | Shallow Immutability of Snapshot | `HubSnapshot` is defined with `dataclass(frozen=True)` which prevents attribute reassignments. However, contained lists and dictionaries remain standard mutable Python collections. **UI Constraint:** The UI must treat `HubSnapshot` and all nested view models as strictly read-only and must never mutate any collection or object in place. |
| **Gap 12** | Missing Network / SLA / Cost Metrics | The backend does not measure or calculate provider latency distributions, requests per second (RPS), error rate percentages, monetary cost metrics, or external provider SLA uptime percentages. **UI Constraint:** The UI must display `Н/Д` (Нет данных) or hide these metric cards entirely. The UI must never generate fictional numbers or place random mock graphs in dashboard/provider cards. |
---

View file

@ -15,6 +15,12 @@ from antigravity_provider.router.router_engine import RouterEngine, get_router_e
from antigravity_provider.router.adapters import get_adapter from antigravity_provider.router.adapters import get_adapter
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
def print_router_status() -> int: def print_router_status() -> int:
"""Print pool and health status of all profiles and role chains.""" """Print pool and health status of all profiles and role chains."""
@ -225,6 +231,38 @@ def simulate_quota_cli(profile_id: str, model_family: Optional[str] = None, dura
return 0 return 0
def print_diagnostics_cli() -> int:
"""Print comprehensive diagnostic table for all profiles with provider, identity, auth, quota state and data source."""
from antigravity_provider.router.unified_health import UnifiedHealthService
uh_service = UnifiedHealthService.get()
profiles_by_prov = uh_service.scan_all(force=True)
print("=" * 115)
print("HERMES HUB — DIAGNOSTIC & HEALTH MATRIX")
print("=" * 115)
print(f"{'PROFILE':<18} | {'PROVIDER':<15} | {'IDENTITY':<26} | {'AUTH':<14} | {'QUOTA STATE':<16} | {'DATA SOURCE'}")
print("-" * 115)
for prov, profs in sorted(profiles_by_prov.items()):
for p in profs:
ident = p.account_identity
if len(ident) > 25:
ident = ident[:22] + "..."
quota_st = p.health_state
if p.quota_snapshot:
quota_source = p.quota_snapshot.source
if p.quota_snapshot.is_estimated:
quota_source += " (estimated)"
else:
quota_source = "unconfigured"
print(f"{p.profile_id:<18} | {p.provider:<15} | {ident:<26} | {p.auth_state:<14} | {quota_st:<16} | {quota_source}")
print("-" * 115)
return 0
def clear_cooldown_cli(profile_id: Optional[str] = None) -> int: def clear_cooldown_cli(profile_id: Optional[str] = None) -> int:
"""Clear cooldowns and quota simulations.""" """Clear cooldowns and quota simulations."""
engine = get_router_engine() engine = get_router_engine()
@ -243,6 +281,9 @@ def main(argv: Optional[list[str]] = None) -> int:
# status # status
subparsers.add_parser("status", help="Show pool and health status of all provider profiles") subparsers.add_parser("status", help="Show pool and health status of all provider profiles")
# diag
subparsers.add_parser("diag", help="Show full diagnostic table (identity, auth, quota state, data source)")
# policy # policy
subparsers.add_parser("policy", help="Show role fallback chains and policies") subparsers.add_parser("policy", help="Show role fallback chains and policies")
@ -297,6 +338,8 @@ def main(argv: Optional[list[str]] = None) -> int:
return 0 return 0
elif args.subcommand == "status": elif args.subcommand == "status":
return print_router_status() return print_router_status()
elif args.subcommand == "diag":
return print_diagnostics_cli()
elif args.subcommand == "policy": elif args.subcommand == "policy":
return print_routing_policy() return print_routing_policy()
elif args.subcommand == "profile": elif args.subcommand == "profile":

View file

@ -264,6 +264,25 @@ class RouterEngine:
"selection_trace": selection_trace, "selection_trace": selection_trace,
}) })
if failover_trail:
prev_failed = failover_trail[-1]
logger.warning(
"Router failover for role '%s': switched from failed profile '%s' to '%s'",
target_role,
prev_failed.get("profile_id"),
pid,
)
try:
from antigravity_provider.router.unified_health import EventLogService
EventLogService.get().log(
category="routing",
message=f"Успешное переключение роли '{target_role}': резервный профиль '{pid}'",
details=f"Предыдущий профиль '{prev_failed.get('profile_id')}' не ответил: {prev_failed.get('error')}",
level="info",
)
except Exception:
pass
return response return response
except Exception as exc: except Exception as exc:
@ -307,11 +326,33 @@ class RouterEngine:
"error": err_class.message[:200], "error": err_class.message[:200],
}) })
try:
from antigravity_provider.router.unified_health import EventLogService
EventLogService.get().log(
category="routing",
message=f"Переключение маршрута для роли '{target_role}': сбой профиля '{pid}' ({pconfig.provider})",
details=f"Причина: {err_class.message[:180]}",
level="warning",
)
except Exception:
pass
# If non-fatal and more profiles remain in chain, continue loop (failover!) # If non-fatal and more profiles remain in chain, continue loop (failover!)
continue continue
# All attempts in chain failed # All attempts in chain failed
summary_errors = "; ".join(f"[{t.get('profile_id')}]: {t.get('error', t.get('status'))}" for t in failover_trail) summary_errors = "; ".join(f"[{t.get('profile_id')}]: {t.get('error', t.get('status'))}" for t in failover_trail)
try:
from antigravity_provider.router.unified_health import EventLogService
EventLogService.get().log(
category="routing",
message=f"Все маршруты для роли '{target_role}' исчерпаны ({attempts} попыток)",
details=summary_errors,
level="error",
)
except Exception:
pass
return { return {
"id": f"router-fail-{int(time.time())}", "id": f"router-fail-{int(time.time())}",
"object": "chat.completion", "object": "chat.completion",

View file

@ -105,6 +105,8 @@ class HubStateStore:
"""Return the current cached snapshot. Generates an initial snapshot if none exists.""" """Return the current cached snapshot. Generates an initial snapshot if none exists."""
with self._lock: with self._lock:
if self._current_snapshot is not None: if self._current_snapshot is not None:
if (time.time() - self._current_snapshot.timestamp > 300.0) and not self._current_snapshot.is_stale:
self._current_snapshot = replace(self._current_snapshot, is_stale=True)
return self._current_snapshot return self._current_snapshot
return self.refresh(force_scan=False) return self.refresh(force_scan=False)

View file

@ -213,3 +213,20 @@ def test_plan_source_and_pipeline_node_enrichment():
assert hasattr(node, "quota_status") assert hasattr(node, "quota_status")
assert hasattr(node, "failover_reason") assert hasattr(node, "failover_reason")
@pytest.mark.unit
def test_snapshot_is_stale_policy():
"""Verify is_stale policy: fresh on creation, stale on bootstrap and after 300s TTL."""
store = HubStateStore()
empty = store._build_empty_snapshot()
assert empty.is_stale is True
fresh = store.refresh(force_scan=False)
assert fresh.is_stale is False
# Simulate aged snapshot
from dataclasses import replace
store._current_snapshot = replace(fresh, timestamp=time.time() - 301.0)
cached = store.get_snapshot()
assert cached.is_stale is True