feat(release): close contract audit v1.2, release feed verification, diagnostic CLI, and failover event logging
This commit is contained in:
parent
9ffc815ed5
commit
ff8776b963
6 changed files with 202 additions and 16 deletions
69
agents/done/2026-08-21-A3-antigravity-release-readiness.md
Normal file
69
agents/done/2026-08-21-A3-antigravity-release-readiness.md
Normal 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`
|
||||
|
|
@ -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. |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue