From ff8776b963a8553c7dd773a6d01fd7f2c9df9618 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Fri, 21 Aug 2026 10:05:30 +0700 Subject: [PATCH] feat(release): close contract audit v1.2, release feed verification, diagnostic CLI, and failover event logging --- ...-08-21-A3-antigravity-release-readiness.md | 69 +++++++++++++++++++ docs/UI_STATE_CONTRACT.md | 46 ++++++++----- .../router/cli_commands.py | 43 ++++++++++++ .../router/router_engine.py | 41 +++++++++++ .../router/state_store.py | 2 + ...test_state_layer_and_event_driven_quota.py | 17 +++++ 6 files changed, 202 insertions(+), 16 deletions(-) create mode 100644 agents/done/2026-08-21-A3-antigravity-release-readiness.md diff --git a/agents/done/2026-08-21-A3-antigravity-release-readiness.md b/agents/done/2026-08-21-A3-antigravity-release-readiness.md new file mode 100644 index 0000000..3b6885d --- /dev/null +++ b/agents/done/2026-08-21-A3-antigravity-release-readiness.md @@ -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` diff --git a/docs/UI_STATE_CONTRACT.md b/docs/UI_STATE_CONTRACT.md index 501d82d..b687a48 100644 --- a/docs/UI_STATE_CONTRACT.md +++ b/docs/UI_STATE_CONTRACT.md @@ -1,8 +1,8 @@ # Hermes Hub UI state contract -- Contract version: **1.1** -- Published against: **`e8a404be035fa04b5f76e3e572c6539fba0e83e4`** -- Contract owner: `antigravity/contract-gaps` +- Contract version: **1.2** +- Published against: **`9ffc815ed59f7fd364e176ffef20bf29d545bc46`** +- Contract owner: `antigravity/release-readiness` - Consumer: `codex/ui-redesign` 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"`. | | `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 | |---|---|---|---|---| @@ -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. -| 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_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_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_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` | @@ -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 3** | Public `seq` in `HubSnapshot` | **Closed** | `HubSnapshot.seq` exposed to UI; matches accepted refresh token. | -| **Gap 6** | Plan provenance for `PlanBadge` | **Closed** | `ProfileViewModel.plan_source` added (`"provider_api"`, `"jwt_claim"`, `"provider_auth"`, `"inferred"`, `"unknown"`). | -| **Gap 7** | `AgentViewModel` active session and quota | **Closed** | `session_id`, `active_quota_status`, and `active_quota_label` added. | -| **Gap 8** | `PipelineNode` identity, quota, and failover reason | **Closed** | `account_identity`, `quota_status`, and real `failover_reason` added. | -| **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 10** | Scheduler async quota race | **Closed** | Scheduler triggers complete quota collection before invoking snapshot rebuild. | -| **Gap 11** | Stale response protection verification | **Closed** | `seq` recorded only on completion; late responses strictly dropped with test proof. | +| **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 (`2035c14`). | +| **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 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 7** | `AgentViewModel` active session and quota | **Closed** | `session_id`, `active_quota_status`, and `active_quota_label` added (`2035c14`). | +| **Gap 8** | `PipelineNode` identity, quota, and failover reason | **Closed** | `account_identity`, `quota_status`, and real `failover_reason` added (`2035c14`). | +| **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 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. | + +--- diff --git a/src/antigravity_provider/router/cli_commands.py b/src/antigravity_provider/router/cli_commands.py index 216b422..129bfa6 100644 --- a/src/antigravity_provider/router/cli_commands.py +++ b/src/antigravity_provider/router/cli_commands.py @@ -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.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: """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 +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: """Clear cooldowns and quota simulations.""" engine = get_router_engine() @@ -243,6 +281,9 @@ def main(argv: Optional[list[str]] = None) -> int: # status 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 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 elif args.subcommand == "status": return print_router_status() + elif args.subcommand == "diag": + return print_diagnostics_cli() elif args.subcommand == "policy": return print_routing_policy() elif args.subcommand == "profile": diff --git a/src/antigravity_provider/router/router_engine.py b/src/antigravity_provider/router/router_engine.py index 9f74eaa..5de1c28 100644 --- a/src/antigravity_provider/router/router_engine.py +++ b/src/antigravity_provider/router/router_engine.py @@ -264,6 +264,25 @@ class RouterEngine: "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 except Exception as exc: @@ -307,11 +326,33 @@ class RouterEngine: "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!) continue # All attempts in chain failed 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 { "id": f"router-fail-{int(time.time())}", "object": "chat.completion", diff --git a/src/antigravity_provider/router/state_store.py b/src/antigravity_provider/router/state_store.py index f923bf1..93aaf99 100644 --- a/src/antigravity_provider/router/state_store.py +++ b/src/antigravity_provider/router/state_store.py @@ -105,6 +105,8 @@ class HubStateStore: """Return the current cached snapshot. Generates an initial snapshot if none exists.""" with self._lock: 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.refresh(force_scan=False) diff --git a/tests/test_state_layer_and_event_driven_quota.py b/tests/test_state_layer_and_event_driven_quota.py index 458cfbc..5b088fa 100644 --- a/tests/test_state_layer_and_event_driven_quota.py +++ b/tests/test_state_layer_and_event_driven_quota.py @@ -213,3 +213,20 @@ def test_plan_source_and_pipeline_node_enrichment(): assert hasattr(node, "quota_status") 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 +