feat(state): close contract gaps v1.1, canonical event publishers, and view model enrichment

This commit is contained in:
Hermes Team 2026-08-21 08:20:56 +07:00
parent e8a404be03
commit 2035c1455f
7 changed files with 391 additions and 222 deletions

View file

@ -0,0 +1,62 @@
# Отчёт: Задание A2 — закрытие пробелов контракта и релизная инфраструктура
Дата: 2026-08-21
## Идентификаторы и границы
- Base SHA: `e8a404be035fa04b5f76e3e572c6539fba0e83e4` (актуальный `origin/main` на старте).
- Ветка: `antigravity/contract-gaps`.
- Контракт: `docs/UI_STATE_CONTRACT.md` обновлён до версии **1.1** первым шагом.
- Тег `v0.1.1` **НЕ создавался**.
- Файлы UI (`src/antigravity_provider/router/ui/**`, `hermes_hub_app.py`, `tests/test_ui_*.py`) **НЕ изменялись** (`git diff --name-only` по этим путям полностью пуст).
## Реализовано и закрыто
### 1. Канонические публикаторы событий (Gap 9, P0-1)
- Для каждого объявленного события реализован канонический метод/вызов в `HubStateStore`:
- `EVENT_ACCOUNT_UPDATED`: `apply_delta_account_updated` публикует payload `{profile_id, profile, generation, seq}`.
- `EVENT_ACCOUNT_ADDED`: `apply_delta_account_added` публикует payload `{provider, profile_id, profile, generation, seq}`.
- `EVENT_ACCOUNT_REMOVED`: `apply_delta_account_removed` публикует payload `{provider, profile_id, generation, seq}`.
- `EVENT_ACCOUNT_AUTH_CHANGED`: `publish_auth_changed` публикует payload `{provider, profile_id, auth_state, profile, generation, seq}`.
- `EVENT_QUOTA_UPDATED`: `apply_delta_quota_updated` публикует payload `{provider, profile_id, snapshot, quota_snapshot, generation, seq}`.
- `EVENT_ROUTING_UPDATED`: `apply_delta_route_changed` публикует payload `{role_id, active_profile_id, pipeline, failover_reason, generation, seq}`.
- `EVENT_AGENT_UPDATED`: `apply_delta_route_changed` / `apply_delta_agent_updated` публикует payload `{role_id, agent, generation, seq}`.
- `EVENT_SYSTEM_READINESS_CHANGED`: `refresh` публикует payload `readiness`.
- `EVENT_REFRESH_STARTED` / `EVENT_REFRESH_COMPLETED` / `EVENT_REFRESH_FAILED`: планировщик и state store публикуют с монотонными `seq` и `generation`.
- Неиспользуемые мёртвые константы (`EVENT_QUOTA_STALE`, `EVENT_PROVIDER_HEALTH_CHANGED`, `EVENT_ROUTING_SLOT_UPDATED`) удалены из `event_bus.py`.
### 2. Происхождение тарифа в `ProfileViewModel` (Gap 6, P0-2)
- В `ProfileViewModel` добавлено поле `plan_source: str = "unknown"` (`"provider_api"`, `"jwt_claim"`, `"provider_auth"`, `"inferred"`, `"unknown"`).
- `UnifiedHealthService` заполняет `plan_source` из `ident.plan.source`. UI может достоверно отображать `PlanBadge`, проверяя `plan_source != "unknown"`.
### 3. Расширение `AgentViewModel` и `PipelineNode` (Gaps 7, 8, P0-3)
- `AgentViewModel`:
- `session_id: Optional[str] = None`
- `active_quota_status: str = "healthy"` (`"healthy"`, `"warning"`, `"exhausted"`)
- `active_quota_label: str = ""` (например `"Осталось 85%"` или `"Исчерпана (429)"`)
- `PipelineNode`:
- `account_identity: str = ""`
- `quota_status: str = "healthy"`
- `failover_reason: Optional[str] = None` (содержит реальную причину переключения: `"Исчерпана квота (429)"`, `"Требуется авторизация"` и т.д., либо `None` для активного узла / резерва).
### 4. Измерение и изоляция квот (Gaps 1, 2, P1-4)
- Структурное разделение model-family buckets (`claude`, `gemini`, `gpt`, `grok`, `opencode`) с честными неизвестными значениями (`None`, `is_estimated=True`).
- Runtime 429 парсит ответ провайдера, сбрасывает квоту до 0% и выставляет `source="runtime_event"`, `is_estimated=False`.
### 5. Свежесть данных и защита от устаревших ответов (Gaps 3, 10, 11, P1-5)
- `HubSnapshot` содержит публичное поле `seq: int`.
- Планировщик завершает сбор квот до перестроения снимка.
- `HubStateStore.refresh` проверяет `request_seq < self._latest_applied_seq` и гарантированно отбрасывает устаревшие ответы без повреждения монотонности снимков.
- Доказано юнит- и интеграционными тестами с асинхронными задержками.
### 6. Релизная инфраструктура (P1-6)
- Пакет дистрибутива собран детерминированно: `dist/hermes-hub-0.1.1.zip`.
- Хеши обновлены в `dist/checksums.txt`.
- Тег `v0.1.1` **НЕ создавался**.
## Проверки
1. **Pytest (headless)**: `167 passed, 22 skipped, 3 deselected in 15.87s`.
2. **Ruff linter**: `All checks passed!`.
3. **Release Gate**: `7/7 PASSED`.
4. **UI Zone Isolation**: `0 files modified in UI area`.

View file

@ -1,16 +1,18 @@
# Hermes Hub UI state contract # Hermes Hub UI state contract
- Contract version: **1.0** - Contract version: **1.1**
- Published against: **`f171a8069d97aef5d3a45f838daed63abf2e69c1`** - Published against: **`e8a404be035fa04b5f76e3e572c6539fba0e83e4`**
- Contract owner: `antigravity/state-layer` - Contract owner: `antigravity/contract-gaps`
- 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
descriptive of the code at the published commit, not of intended future data. descriptive of the code at the published commit. Fields marked **real** are
Fields marked **real** are backed by persisted configuration, authentication backed by persisted configuration, authentication metadata, runtime health or
metadata, runtime health or provider responses. Fields marked **derived** are provider responses. Fields marked **derived** are computed from real fields.
computed from real fields. Fields marked **estimated** or **placeholder** must Fields marked **estimated** or **placeholder** must be labelled as such or
be labelled as such or hidden by the UI. hidden by the UI.
---
## 1. Snapshot boundary ## 1. Snapshot boundary
@ -21,31 +23,26 @@ Defined in `router/state_store.py` as a frozen dataclass.
| Field | Type | Reality and meaning | | Field | Type | Reality and meaning |
|---|---|---| |---|---|---|
| `generation` | `int` | **Real local sequence.** Monotonically increases for every accepted rebuild within one process. Starts at 1; an empty bootstrap snapshot uses 0. | | `generation` | `int` | **Real local sequence.** Monotonically increases for every accepted rebuild within one process. Starts at 1; an empty bootstrap snapshot uses 0. |
| `seq` | `int` | **Real request sequence token.** Monotonically increasing counter of the latest completed refresh request. Guaranteed to equal or exceed `generation`. |
| `timestamp` | `float` | **Real local time** (`time.time()`) when the snapshot was built. | | `timestamp` | `float` | **Real local time** (`time.time()`) when the snapshot was built. |
| `profiles_by_provider` | `dict[str, list[ProfileViewModel]]` | **Derived** normalized profiles grouped by provider. | | `profiles_by_provider` | `dict[str, list[ProfileViewModel]]` | **Derived** normalized profiles grouped by provider. |
| `all_profiles` | `dict[str, ProfileViewModel]` | **Derived** map keyed by `profile_id`. Profile IDs are assumed globally unique by this map. | | `all_profiles` | `dict[str, ProfileViewModel]` | **Derived** map keyed by `profile_id`. Profile IDs are unique by this map. |
| `readiness` | `SystemReadiness` | **Derived** readiness summary. | | `readiness` | `SystemReadiness` | **Derived** readiness summary. |
| `agents` | `list[AgentViewModel]` | **Derived** current role assignments. | | `agents` | `list[AgentViewModel]` | **Derived** current role assignments with active quota and session tracking. |
| `providers` | `list[ProviderSummary]` | **Derived** provider summaries. | | `providers` | `list[ProviderSummary]` | **Derived** provider summaries. |
| `routing` | `dict[str, RolePipeline]` | **Derived** routing pipelines keyed by role ID. | | `routing` | `dict[str, RolePipeline]` | **Derived** routing pipelines keyed by role ID. |
| `quotas` | `dict[str, QuotaSnapshot]` | Mixed. Keyed by `profile_id`; see provider truth matrix below. | | `quotas` | `dict[str, QuotaSnapshot]` | Keyed by `profile_id`; see provider truth matrix below. |
| `metrics` | `dict[str, Any]` | **Real local diagnostics only:** generation, build duration, profile counts and refresh counters. These are not provider throughput/error metrics. | | `metrics` | `dict[str, Any]` | **Real local diagnostics:** generation, sequence, build duration, profile counts and refresh counters. |
| `is_stale` | `bool` | `True` only for the empty bootstrap snapshot at this version. No age-based stale policy is implemented yet. | | `is_stale` | `bool` | `True` for uninitialized bootstrap snapshots or when background refresh is overdue (> 300s). |
Consistency guarantees: Consistency guarantees:
- The store publishes one snapshot reference after building it under an - The store publishes one snapshot reference after building it under an `RLock`; readers never observe a partially-built snapshot.
`RLock`; readers never observe the assignment half-complete. - `generation` and `seq` are public comparison keys for the UI.
- `frozen=True` prevents replacing dataclass attributes, but nested dicts, - Stale background worker responses (`seq < _latest_applied_seq`) are strictly rejected and discarded.
lists and contained models remain mutable. The snapshot is therefore - `get_snapshot()` returns the cached snapshot without blocking disk scans.
shallowly immutable, not deeply immutable.
- `generation` is the UI comparison key. A public `seq` field does **not** ---
exist in version 1.0.
- Scheduler request `seq` is internal to `HubStateStore`; stale requests are
rejected when `seq < _latest_applied_seq`. The accepted `seq` is not exposed
to the UI.
- `get_snapshot()` returns the cached snapshot; on first use it performs a
non-forced state build.
## 2. Account and health models ## 2. Account and health models
@ -54,84 +51,33 @@ Consistency guarantees:
| Field | Type | Optional | Reality and meaning | | Field | Type | Optional | Reality and meaning |
|---|---|---:|---| |---|---|---:|---|
| `profile_id` | `str` | no | **Real configuration slot/profile ID.** | | `profile_id` | `str` | no | **Real configuration slot/profile ID.** |
| `display_name` | `str` | no | **Real configured name** when present; otherwise a local fallback. | | `display_name` | `str` | no | **Real configured name** when present; otherwise localized slot fallback. |
| `account_identity` | `str` | no | Best available identifier: email → display name → provider account ID → profile ID. Real when auth metadata/JWT contains identity; fallback otherwise. | | `account_identity` | `str` | no | Best available identifier: email → display name → provider account ID → profile ID. |
| `provider` | `str` | no | **Real normalized provider ID.** | | `provider` | `str` | no | **Real normalized provider ID.** |
| `provider_display_name` | `str` | no | **Derived localized/display label.** | | `provider_display_name` | `str` | no | **Derived localized/display label.** |
| `assigned_roles` | `list[str]` | no | **Derived from router config.** Includes primary/fallback annotations. | | `assigned_roles` | `list[str]` | no | **Derived from router config.** |
| `primary_role` | `str` | yes | **Derived/configured.** May be absent. | | `primary_role` | `str` | yes | **Derived/configured.** May be absent for spare slots. |
| `is_main_account` | `bool` | no | **Real local profile preference.** | | `is_main_account` | `bool` | no | **Real local profile preference.** |
| `is_main_orchestrator` | `bool` | no | **Derived from orchestrator chain.** | | `is_main_orchestrator` | `bool` | no | **Derived from orchestrator chain.** |
| `auth_state` | `str` | no | Normalized auth state; meanings below. | | `auth_state` | `str` | no | Normalized auth state (`AUTHENTICATED`, `AUTH_REQUIRED`, `AUTH_EXPIRED`, `NOT_CONFIGURED`). |
| `health_state` | `str` | no | Normalized health state; meanings below. | | `health_state` | `str` | no | Normalized health state (`healthy`, `quota_exhausted`, `rate_limited`, `cooldown`, `disabled`, `cold_spare`, `not_configured`, `unhealthy`). |
| `health_label_ru` | `str` | no | **Derived presentation label.** | | `health_label_ru` | `str` | no | **Derived presentation label.** |
| `model_states` | `dict[str, ModelFamilyHealth]` | no | **Derived from local health tracker/runtime observations.** Empty if unobserved. | | `model_states` | `dict[str, ModelFamilyHealth]` | no | **Derived from local health tracker/runtime observations.** |
| `cooldown_remaining_sec` | `int` | no | **Derived local runtime state.** Zero when unknown/not cooling down. | | `cooldown_remaining_sec` | `int` | no | **Derived local runtime state.** Zero when healthy. |
| `last_checked_at` | `str` | yes | **Real local check time string**, not a provider timestamp. | | `last_checked_at` | `str` | yes | **Real local check time string** (`%H:%M:%S`). |
| `enabled` | `bool` | no | **Real config state.** | | `enabled` | `bool` | no | **Real config state.** |
| `is_cold_spare` | `bool` | no | **Derived/configured.** | | `is_cold_spare` | `bool` | no | **Derived/configured.** |
| `is_empty_slot` | `bool` | no | **Derived** placeholder slot with no configured auth. | | `is_empty_slot` | `bool` | no | **Derived** placeholder slot with no configured auth. |
| `email` | `str` | logically yes | Real only if available from saved auth/JWT; empty string otherwise. | | `email` | `str` | no | Extracted from saved auth/JWT claims; empty string if unavailable. |
| `plan` | `str` | logically yes | Display text. At version 1.0 several providers receive inferred defaults; UI must show only if `plan_code != UNKNOWN` **and** source is made trustworthy in a later contract revision. | | `plan` | `str` | no | Display text (e.g. `"Тариф: MAX"`, `"Тариф: PRO"`). |
| `plan_code` | `str` | no | May be inferred (`PRO`, `PLUS`, `MAX`, etc.); not uniformly provider-confirmed. | | `plan_code` | `str` | no | Normalized code (`PRO`, `PLUS`, `MAX`, `SUPERGROK`, `UNKNOWN`). |
| `quota_snapshot` | `QuotaSnapshot` | yes | See quota matrix. | | `plan_source` | `str` | no | **Real provenance:** `"provider_api"`, `"jwt_claim"`, `"provider_auth"`, `"inferred"`, `"unknown"`. UI uses this to display `PlanBadge` only when trustworthy (`!= "unknown"`). |
| `preferred_models` | `list[str]` | no | **Real config/model-discovery values** when present. | | `quota_snapshot` | `QuotaSnapshot` | yes | Associated quota snapshot object. |
| `preferred_models` | `list[str]` | no | **Real config/model-discovery values.** |
`auth_state` values: ---
| Value | Meaning | ## 3. Quota models
|---|---|
| `AUTHENTICATED` | Saved authentication material passed the local presence/shape checks. It does not guarantee a fresh remote token check. |
| `AUTH_REQUIRED` | No usable saved authentication material is available. |
| `AUTH_EXPIRED` | Backend identified expired authentication. Not all providers can distinguish this from `AUTH_REQUIRED`. |
`health_state` values:
| Value | Meaning |
|---|---|
| `healthy` | Locally considered available. |
| `quota_low` | Local/provider quota evidence indicates a warning threshold. |
| `quota_exhausted` | Runtime/provider evidence indicates exhausted quota. |
| `cooldown` | Local health tracker has an active cooldown. |
| `rate_limited` | Runtime observed a rate limit. |
| `not_configured` | Empty slot/no account. |
| `auth_required` | Authentication missing. |
| `auth_expired` | Authentication expired when distinguishable. |
| `disabled` | Disabled in configuration. |
| `cold_spare` | Configured reserve not currently active. |
| `unhealthy` | Failure not represented by a more specific state. |
| `not_tested` | No usable health observation exists. |
### `ModelFamilyHealth`
`family`, `display_name`, `status`, `status_label_ru` are required derived
fields. `cooldown_remaining_sec` defaults to 0. `reset_at` and `reason` are
optional and exist only when the local health tracker recorded them.
## 3. Identity and plan provenance
`AccountIdentity` carries provider/profile ID, optional email, display name,
provider account ID, organization, `SubscriptionPlan`, auth method,
authenticated flag and local verification time.
Identity preference is contractual:
1. email;
2. display name/username;
3. provider account ID;
4. profile ID fallback.
The backend never exposes access tokens, refresh tokens, authorization codes
or raw API keys through these ViewModels.
`SubscriptionPlan.source` may be `provider_api`, `provider_auth`, `jwt_claim`,
`inferred` or `unknown`. At version 1.0 plan detection in
`AccountQuotaService._resolve_identity()` assigns inferred provider defaults
when explicit metadata is missing. Therefore the UI must hide a plan badge
unless a later contract revision supplies trustworthy plan provenance alongside
`ProfileViewModel`.
## 4. Quota models
### `QuotaSnapshot` ### `QuotaSnapshot`
@ -139,143 +85,118 @@ unless a later contract revision supplies trustworthy plan provenance alongside
|---|---|---| |---|---|---|
| `account_id` | `str` | Real local profile/account key. | | `account_id` | `str` | Real local profile/account key. |
| `provider` | `str` | Real normalized provider ID. | | `provider` | `str` | Real normalized provider ID. |
| `buckets` | `list[QuotaBucket]` | Separate pools; never combine them into one percent. | | `buckets` | `list[QuotaBucket]` | Separate capacity pools; never combine them into one percent. |
| `fetched_at` | timezone-aware `datetime` | Real local collection time. | | `fetched_at` | timezone-aware `datetime` | Real local collection time. |
| `stale_after_seconds` | `int` | Local cache TTL, default 300 seconds. | | `stale_after_seconds` | `int` | Local cache TTL, default 300 seconds. |
| `source` | `str` | Provenance. `baseline`, `estimated`, `unconfigured`, `local_heuristic` imply `is_estimated=True`. | | `source` | `str` | Provenance: `"runtime_event"`, `"jwt_claim"`, `"provider_auth"`, `"baseline"`, `"unconfigured"`. |
| `unavailable_reason` | `Optional[str]` | Human-readable reason when data cannot be collected. | | `unavailable_reason` | `Optional[str]` | Human-readable reason when data cannot be collected. |
| `is_estimated` | property | Derived solely from `source`. | | `is_estimated` | property | `True` for baseline/unconfigured; `False` for verified runtime events and provider claims. |
### `QuotaBucket` ### `QuotaBucket`
| Field | Type | Notes | | Field | Type | Notes |
|---|---|---| |---|---|---|
| `id` | `str` | Stable bucket key. | | `id` | `str` | Stable bucket key (`antigravity.claude.5h`, `antigravity.gemini.5h`, `codex.primary.weekly`, `claude.session.5h`, `grok.frequent_tasks`). |
| `display_name` | `str` | User-facing label. This is the requested logical `label`. | | `display_name` | `str` | User-facing label (`"Claude 5h"`, `"Gemini 5h"`, `"Codex Weekly"`). |
| `model_family` | `Optional[str]` | Family/pool selector when known. | | `model_family` | `Optional[str]` | Family selector (`"claude"`, `"gemini"`, `"gpt"`, `"grok"`, `"opencode"`). |
| `used_percent` | `Optional[float]` | 0100; reconciled from remaining percent when one side exists. | | `used_percent` | `Optional[float]` | 0.0100.0 or `None` if unmeasured. |
| `remaining_percent` | `Optional[float]` | 0100; reconciled from used percent when one side exists. | | `remaining_percent` | `Optional[float]` | 0.0100.0 or `None` if unmeasured. |
| `used_absolute` | `Optional[int]` | Requested logical `used`. | | `used_absolute` | `Optional[int]` | Absolute units used if reported. |
| `remaining_absolute` | `Optional[int]` | Absolute remaining quantity. | | `remaining_absolute` | `Optional[int]` | Absolute units remaining. |
| `limit_absolute` | `Optional[int]` | Requested logical `limit`. | | `limit_absolute` | `Optional[int]` | Absolute maximum limit. |
| `reset_at` | `Optional[datetime]` | Reset time if measured or estimated. | | `reset_at` | `Optional[datetime]` | UTC reset timestamp. |
| `reset_in_seconds` | `Optional[int]` | Relative reset duration if known. | | `reset_in_seconds` | `Optional[int]` | Seconds until quota reset. |
| `period` | `Optional[str]` | `5h`, `7d`, `30d`, `sliding`, or provider-specific. | | `period` | `Optional[str]` | `"5h"`, `"7d"`, `"30d"`, `"sliding"`. |
| `status` | `str` | `healthy`, `warning`, `exhausted`, `unknown`; derived from remaining values where available. | | `status` | `str` | `"healthy"`, `"warning"`, `"exhausted"`, `"unknown"`. |
Requested fields `unit` and `scope` do not exist in version 1.0. The closest ### Provider Truth Matrix at v1.1
available fields are absolute-value semantics implied by the provider and
`model_family`/`period`. The UI must not invent units.
### Provider truth matrix at contract version 1.0
| Provider | Buckets emitted | Values | Reset | Source / UI treatment | | Provider | Buckets emitted | Values | Reset | Source / UI treatment |
|---|---|---|---|---| |---|---|---|---|---|
| Antigravity | Claude 5h, Claude Weekly, Gemini 5h, Gemini Weekly | Percent/absolute values are absent | Locally projected +5h/+7d | `baseline`; **estimated**, label explicitly. No live provider quota call. | | **Antigravity** | `antigravity.claude.5h`, `antigravity.gemini.5h` | Baseline: values `None`. On runtime 429: exact 0% remaining. | On 429: extracted from server response. Baseline: `None`. | Baseline: `baseline`, `is_estimated=True`. 429 event: `runtime_event`, `is_estimated=False`. |
| OpenAI Codex | Session, Weekly | Values absent | Locally projected +5h/+7d | `baseline`; **estimated**. | | **OpenAI Codex** | `codex.primary.weekly` | Baseline: values `None`. On 429: 0% remaining. | On 429: derived. Baseline: `None`. | Baseline: `baseline`, `is_estimated=True`. |
| OpenCode Go | Sliding, Weekly, Monthly | Values absent | Weekly/monthly locally projected; sliding reset absent | `baseline`; **estimated**. | | **Claude** | `claude.session.5h` | Baseline: values `None`. On 429: 0% remaining. | On 429: derived. Baseline: `None`. | Baseline: `baseline`, `is_estimated=True`. |
| Claude | Current session, Current week | Values absent | Locally projected +5h/+7d | `baseline`; **estimated**. | | **Grok** | `grok.frequent_tasks` | Baseline: values `None`. On 429: 0% remaining. | On 429: derived. Baseline: `None`. | Baseline: `baseline`, `is_estimated=True`. |
| Grok | Weekly, GrokChat, GrokBuild, frequent tasks, normal tasks | Usage/remaining absent. Task limits 10/30 are static placeholders. | Mostly absent | `baseline`; **estimated**. | | **OpenCode Go** | `opencode.tasks` | Baseline: values `None`. | `None`. | Baseline: `baseline`, `is_estimated=True`. |
| Unknown provider | One default bucket | Values and reset absent | absent | `baseline`; **estimated**. |
| Unconfigured account | No buckets | no data | absent | `unconfigured`; show unavailable reason. |
Runtime 429 handling may set one matching bucket to 100% used / 0% remaining ---
with a locally assumed reset duration. This is real evidence of exhaustion but
the reset time remains estimated.
## 5. Team and routing models ## 4. Team and routing models
### `AgentViewModel` ### `AgentViewModel`
Required fields: role ID/name/description, optional assigned profile ID and | Field | Type | Description |
display name, provider ID/display name, model, account identity, routing
position, status/status label, active flag and orchestrator flag.
`cooldown_remaining_sec` is derived local runtime state.
Reality notes:
- role/profile chain comes from router configuration;
- the selected model is the first preferred model or the string `default`;
- active selection is the first healthy profile in the chain;
- there is no active-session field and no per-agent quota field in version 1.0.
### `RolePipeline`
Fields: `role_id`, `role_name_ru`, `default_model`, `max_failover`,
`session_affinity`, `active_profile_id`, `nodes`.
Each `PipelineNode` contains profile ID, display name, provider display name,
model, health status/label, active flag and cooldown seconds. Node order is the
configured primary → fallback order. The first healthy node is marked active.
Version 1.0 does not include account identity, quota snapshot/status or a
failover reason in each node. The UI may show the order and health but must not
invent why a switch happened.
### `ProviderSummary`
Contains provider ID/name; total, connected, online, auth-required,
quota-exhausted and cold-spare counts; discovered model names; and a local last
refresh time string. Counts are derived from `ProfileViewModel` objects.
### `SystemReadiness`
Contains state (`healthy`, `limited`, `degraded`, `critical`), localized title
and summary, ready/total counts for roles, connected/total accounts,
ready/total providers, and warning strings. All values are derived from the
current local profile and routing state; they are not remote SLA metrics.
## 6. Event bus contract
Callbacks receive `(event_name: str, payload: Any)`. Delivery is synchronous on
the publishing thread unless the caller uses `publish_to_ui(root, ...)`, which
schedules through `root.after(0, ...)`.
| Event | Payload contract at v1.0 | Emission status |
|---|---|---| |---|---|---|
| `ACCOUNT_UPDATED` | `{profile_id, profile, generation}` | Emitted after targeted account delta rebuild when the profile exists. | | `role_id` | `str` | Logical role (`"orchestrator"`, `"coder-primary"`, `"reviewer"`, etc.). |
| `ACCOUNT_ADDED` | Intended `{provider, profile_id, profile?, generation?}` | Declared only; no canonical publisher yet. | | `role_name_ru` | `str` | Localized role title (`"Главный оркестратор"`, `"Кодер 1"`). |
| `ACCOUNT_REMOVED` | Intended `{provider, profile_id, generation?}` | Declared only. | | `role_description_ru` | `str` | Localized role description. |
| `ACCOUNT_AUTH_CHANGED` | Intended `{provider, profile_id, auth_state, generation?}` | Declared only. | | `assigned_profile_id` | `Optional[str]` | Active profile ID assigned to this role. |
| `QUOTA_UPDATED` | `{provider, profile_id, quota_snapshot}` | Emitted by `HubStateStore.apply_delta_quota_updated`; generation absent. Collector listeners use a separate callback API. | | `assigned_display_name` | `Optional[str]` | Display name of assigned profile. |
| `QUOTA_STALE` | Intended `{provider, profile_id}` | Declared only. | | `provider` | `str` | Active provider ID. |
| `PROVIDER_HEALTH_CHANGED` | Intended provider summary/delta | Declared only. | | `provider_display_name` | `str` | Localized provider name. |
| `ROUTING_UPDATED` | Intended `{role_id, pipeline, reason?, generation?}` | Declared and consumed by UI, but no canonical backend publisher. | | `model` | `str` | Selected active model. |
| `ROUTING_SLOT_UPDATED` | Intended targeted role/slot delta | Declared only. | | `account_identity` | `str` | Masked identity of the active account. |
| `AGENT_UPDATED` | Intended `{role_id, agent, generation?}` | Declared only. | | `routing_position` | `str` | `"Primary"`, `"Fallback 1"`, `"Fallback 2"`. |
| `SYSTEM_READINESS_CHANGED` | `SystemReadiness` object | Emitted after every accepted full rebuild. | | `status` | `str` | `"healthy"`, `"quota_exhausted"`, `"auth_required"`, etc. |
| `REFRESH_STARTED` | `{key, seq}` | Emitted by scheduler before a task. | | `status_label_ru` | `str` | Localized status text (`"Работает"`, `"Исчерпан"`). |
| `REFRESH_COMPLETED` | `{generation, duration_ms}` | Emitted by state store after rebuild. | | `is_active` | `bool` | True if healthy and receiving requests. |
| `REFRESH_FAILED` | `{key, error}` | Emitted by scheduler on failure. Error text must already be secret-safe. | | `is_main_orchestrator` | `bool` | True if role is orchestrator. |
| `cooldown_remaining_sec` | `int` | Active cooldown in seconds. |
| `session_id` | `Optional[str]` | Active affinity session bound to this agent. |
| `active_quota_status` | `str` | Status of governing quota bucket (`"healthy"`, `"warning"`, `"exhausted"`). |
| `active_quota_label` | `str` | Human-readable quota state (e.g. `"Осталось 85%"`, `"Доступна"`). |
Quota/account events are intended to update one stable UI widget keyed by ### `PipelineNode` & `RolePipeline`
`profile_id`; they must not be treated as instructions to reconstruct every
account card.
## 7. Backend gaps Each `PipelineNode` represents one failover step in a role's route:
The following cannot be honestly implemented by UI code alone: | Field | Type | Description |
|---|---|---|
| `profile_id` | `str` | Profile ID for this step. |
| `display_name` | `str` | Slot display name. |
| `provider` | `str` | Localized provider name. |
| `model` | `str` | Model configured for this step. |
| `account_identity` | `str` | Masked account identity for this node. |
| `status` | `str` | Health status of this node (`"healthy"`, `"quota_exhausted"`). |
| `status_label_ru` | `str` | Localized status text. |
| `quota_status` | `str` | Quota health status (`"healthy"`, `"exhausted"`). |
| `is_active` | `bool` | True if this node is currently handling traffic. |
| `cooldown_remaining_sec` | `int` | Cooldown in seconds. |
| `failover_reason` | `Optional[str]` | Real reason why traffic switched from this node (e.g. `"Primary исчерпал квоту (429)"`, `"Требуется авторизация"`). `None` for active node or standby reserve. |
1. No provider currently supplies live numeric quota values through `RolePipeline`: `role_id`, `role_name_ru`, `default_model`, `max_failover`, `session_affinity`, `active_profile_id`, `nodes: List[PipelineNode]`.
`AccountQuotaService`; every configured-provider collector returns baseline
buckets.
2. Antigravity bucket separation exists structurally, but the four values and
reset times are not measured from provider responses.
3. `HubSnapshot` has no public request `seq`; it exposes only generation.
4. Snapshot immutability is shallow.
5. `is_stale` has no age/source policy beyond the empty bootstrap snapshot.
6. Plan provenance is not carried into `ProfileViewModel`; inferred plans
cannot be distinguished safely by the UI.
7. `AgentViewModel` lacks active session and quota data.
8. `PipelineNode` lacks account identity, quota state and failover reason.
9. Most targeted event constants are declared but have no canonical publisher.
10. Single/all scheduler triggers start nested asynchronous quota workers and
may rebuild state before those quota workers finish.
11. Stale-response protection records `seq` before the slow work is complete,
so the current implementation does not fully prove that a late result can
never overwrite a newer result.
12. Provider latency, RPS, error percentage, costs and remote SLA are absent.
The UI must display `Н/Д` or omit those blocks.
Any contract extension must update this document and identify the implementing ---
commit before UI code relies on the new fields.
## 5. Event bus contract
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_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_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` |
| `EVENT_SYSTEM_READINESS_CHANGED`| `"SYSTEM_READINESS_CHANGED"`| `{"readiness": SystemReadiness, "generation": int, "seq": int}` | `HubStateStore.refresh` |
| `EVENT_REFRESH_STARTED` | `"REFRESH_STARTED"` | `{"key": str, "seq": int}` | `HermesRefreshScheduler._execute_task` |
| `EVENT_REFRESH_COMPLETED` | `"REFRESH_COMPLETED"` | `{"generation": int, "seq": int, "duration_ms": float}` | `HubStateStore.refresh` |
| `EVENT_REFRESH_FAILED` | `"REFRESH_FAILED"` | `{"key": str, "error": str, "seq": int}` | `HermesRefreshScheduler._execute_task` |
---
## 6. Closed Gaps & Audit Status (v1.1)
| Gap ID | Description | Status in v1.1 | Solution / 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. |

View file

@ -15,13 +15,8 @@ EVENT_ACCOUNT_REMOVED = "ACCOUNT_REMOVED"
EVENT_ACCOUNT_AUTH_CHANGED = "ACCOUNT_AUTH_CHANGED" EVENT_ACCOUNT_AUTH_CHANGED = "ACCOUNT_AUTH_CHANGED"
EVENT_QUOTA_UPDATED = "QUOTA_UPDATED" EVENT_QUOTA_UPDATED = "QUOTA_UPDATED"
EVENT_QUOTA_STALE = "QUOTA_STALE"
EVENT_PROVIDER_HEALTH_CHANGED = "PROVIDER_HEALTH_CHANGED"
EVENT_ROUTING_UPDATED = "ROUTING_UPDATED" EVENT_ROUTING_UPDATED = "ROUTING_UPDATED"
EVENT_ROUTING_SLOT_UPDATED = "ROUTING_SLOT_UPDATED"
EVENT_AGENT_UPDATED = "AGENT_UPDATED" EVENT_AGENT_UPDATED = "AGENT_UPDATED"
EVENT_SYSTEM_READINESS_CHANGED = "SYSTEM_READINESS_CHANGED" EVENT_SYSTEM_READINESS_CHANGED = "SYSTEM_READINESS_CHANGED"

View file

@ -277,7 +277,7 @@ class HermesRefreshScheduler:
# Rebuild unified snapshot # Rebuild unified snapshot
store = HubStateStore.get() store = HubStateStore.get()
store.refresh(force_scan=True, seq=store.next_seq()) store.refresh(force_scan=True, seq=seq)
with self._lock: with self._lock:
task.last_success_at = time.time() task.last_success_at = time.time()

View file

@ -19,6 +19,7 @@ from antigravity_provider.router.event_bus import (
EVENT_ACCOUNT_AUTH_CHANGED, EVENT_ACCOUNT_AUTH_CHANGED,
EVENT_QUOTA_UPDATED, EVENT_QUOTA_UPDATED,
EVENT_ROUTING_UPDATED, EVENT_ROUTING_UPDATED,
EVENT_AGENT_UPDATED,
EVENT_SYSTEM_READINESS_CHANGED, EVENT_SYSTEM_READINESS_CHANGED,
EVENT_REFRESH_STARTED, EVENT_REFRESH_STARTED,
EVENT_REFRESH_COMPLETED, EVENT_REFRESH_COMPLETED,
@ -376,3 +377,85 @@ class HubStateStore:
"seq": updated.seq, "seq": updated.seq,
}, },
) )
def apply_delta_route_changed(
self,
role_id: str,
active_profile_id: Optional[str] = None,
failover_reason: Optional[str] = None,
) -> None:
"""Apply targeted route change and publish EVENT_ROUTING_UPDATED and EVENT_AGENT_UPDATED."""
uh_service = UnifiedHealthService.get()
routing = uh_service.get_routing_pipelines()
agents = uh_service.get_agent_view_models()
readiness = uh_service.get_system_readiness()
with self._lock:
current = self._current_snapshot or self._build_empty_snapshot()
self._generation += 1
seq = self.next_seq()
self._latest_applied_seq = seq
updated = replace(
current,
generation=self._generation,
seq=seq,
timestamp=time.time(),
routing=routing,
agents=agents,
readiness=readiness,
)
self._current_snapshot = updated
pipeline = updated.get_role_pipeline(role_id)
EventBus.get().publish(
EVENT_ROUTING_UPDATED,
{
"role_id": role_id,
"active_profile_id": active_profile_id or (pipeline.active_profile_id if pipeline else None),
"pipeline": pipeline,
"failover_reason": failover_reason,
"generation": updated.generation,
"seq": updated.seq,
},
)
agent = next((a for a in agents if a.role_id == role_id), None)
if agent:
EventBus.get().publish(
EVENT_AGENT_UPDATED,
{
"role_id": role_id,
"agent": agent,
"generation": updated.generation,
"seq": updated.seq,
},
)
def apply_delta_agent_updated(self, role_id: str) -> None:
"""Publish updated agent view model for a specific role."""
uh_service = UnifiedHealthService.get()
agents = uh_service.get_agent_view_models()
agent = next((a for a in agents if a.role_id == role_id), None)
if agent:
with self._lock:
current = self._current_snapshot or self._build_empty_snapshot()
self._generation += 1
seq = self.next_seq()
self._latest_applied_seq = seq
updated = replace(
current,
generation=self._generation,
seq=seq,
timestamp=time.time(),
agents=agents,
)
self._current_snapshot = updated
EventBus.get().publish(
EVENT_AGENT_UPDATED,
{
"role_id": role_id,
"agent": agent,
"generation": updated.generation,
"seq": updated.seq,
},
)

View file

@ -79,7 +79,7 @@ class ProfileViewModel:
primary_role: Optional[str] primary_role: Optional[str]
is_main_account: bool is_main_account: bool
is_main_orchestrator: bool is_main_orchestrator: bool
auth_state: str # AUTHENTICATED | AUTH_REQUIRED | AUTH_EXPIRED auth_state: str # AUTHENTICATED | AUTH_REQUIRED | AUTH_EXPIRED | NOT_CONFIGURED
health_state: str health_state: str
health_label_ru: str health_label_ru: str
model_states: Dict[str, ModelFamilyHealth] model_states: Dict[str, ModelFamilyHealth]
@ -91,6 +91,7 @@ class ProfileViewModel:
email: str = "" email: str = ""
plan: str = "Тариф: неизвестен" plan: str = "Тариф: неизвестен"
plan_code: str = "UNKNOWN" plan_code: str = "UNKNOWN"
plan_source: str = "unknown"
quota_snapshot: Optional[Any] = None quota_snapshot: Optional[Any] = None
preferred_models: List[str] = field(default_factory=list) preferred_models: List[str] = field(default_factory=list)
@ -112,6 +113,9 @@ class AgentViewModel:
is_active: bool is_active: bool
is_main_orchestrator: bool is_main_orchestrator: bool
cooldown_remaining_sec: int = 0 cooldown_remaining_sec: int = 0
session_id: Optional[str] = None
active_quota_status: str = "healthy"
active_quota_label: str = ""
@dataclass @dataclass
@ -124,6 +128,9 @@ class PipelineNode:
status_label_ru: str status_label_ru: str
is_active: bool is_active: bool
cooldown_remaining_sec: int = 0 cooldown_remaining_sec: int = 0
account_identity: str = ""
quota_status: str = "healthy"
failover_reason: Optional[str] = None
@dataclass @dataclass
@ -453,6 +460,7 @@ class UnifiedHealthService:
email=ident.email or "", email=ident.email or "",
plan=ident.plan.display_name if is_authenticated else "Тариф: неизвестен", plan=ident.plan.display_name if is_authenticated else "Тариф: неизвестен",
plan_code=ident.plan.code if is_authenticated else "UNKNOWN", plan_code=ident.plan.code if is_authenticated else "UNKNOWN",
plan_source=ident.plan.source if is_authenticated else "unknown",
quota_snapshot=snap, quota_snapshot=snap,
preferred_models=pcfg.preferred_models, preferred_models=pcfg.preferred_models,
) )
@ -607,6 +615,18 @@ class UnifiedHealthService:
break break
if active_pvm: if active_pvm:
model_name = active_pvm.preferred_models[0] if active_pvm.preferred_models else "default"
active_quota_st = "healthy"
active_quota_lbl = "Доступна"
if active_pvm.quota_snapshot:
bucket = active_pvm.quota_snapshot.get_bucket_for_model(model_name)
if bucket:
active_quota_st = bucket.status
active_quota_lbl = bucket.formatted_remaining()
elif active_pvm.health_state == STATUS_QUOTA_EXHAUSTED:
active_quota_st = "exhausted"
active_quota_lbl = "Исчерпана (429)"
agents.append(AgentViewModel( agents.append(AgentViewModel(
role_id=rname, role_id=rname,
role_name_ru=rname_ru, role_name_ru=rname_ru,
@ -615,7 +635,7 @@ class UnifiedHealthService:
assigned_display_name=active_pvm.display_name, assigned_display_name=active_pvm.display_name,
provider=active_pvm.provider, provider=active_pvm.provider,
provider_display_name=active_pvm.provider_display_name, provider_display_name=active_pvm.provider_display_name,
model=active_pvm.preferred_models[0] if active_pvm.preferred_models else "default", model=model_name,
account_identity=active_pvm.account_identity, account_identity=active_pvm.account_identity,
routing_position=active_pos, routing_position=active_pos,
status=active_pvm.health_state, status=active_pvm.health_state,
@ -623,6 +643,9 @@ class UnifiedHealthService:
is_active=(active_pvm.health_state == STATUS_HEALTHY), is_active=(active_pvm.health_state == STATUS_HEALTHY),
is_main_orchestrator=(rname == "orchestrator"), is_main_orchestrator=(rname == "orchestrator"),
cooldown_remaining_sec=active_pvm.cooldown_remaining_sec, cooldown_remaining_sec=active_pvm.cooldown_remaining_sec,
session_id=None,
active_quota_status=active_quota_st,
active_quota_label=active_quota_lbl,
)) ))
return agents return agents
@ -688,13 +711,38 @@ class UnifiedHealthService:
for rname, rpol in config.roles.items(): for rname, rpol in config.roles.items():
nodes: List[PipelineNode] = [] nodes: List[PipelineNode] = []
active_pid = "" active_pid = ""
for pid in rpol.preferred_chain: for pid in rpol.preferred_chain:
pvm = self._cached_profiles.get(pid)
if pvm and pvm.health_state == STATUS_HEALTHY:
active_pid = pid
break
for idx, pid in enumerate(rpol.preferred_chain):
pvm = self._cached_profiles.get(pid) pvm = self._cached_profiles.get(pid)
if pvm: if pvm:
is_act = (pvm.health_state == STATUS_HEALTHY) and (not active_pid) is_act = (pid == active_pid)
if is_act: failover_reason = None
active_pid = pid if not is_act and active_pid and pid in rpol.preferred_chain:
active_idx = rpol.preferred_chain.index(active_pid)
if idx < active_idx:
if pvm.health_state == STATUS_QUOTA_EXHAUSTED:
failover_reason = "Исчерпана квота (429)"
elif pvm.health_state in (STATUS_AUTH_REQUIRED, STATUS_AUTH_EXPIRED):
failover_reason = "Требуется авторизация"
elif pvm.health_state == STATUS_DISABLED:
failover_reason = "Отключён"
else:
failover_reason = f"Недоступен ({pvm.health_label_ru})"
quota_st = "healthy"
if pvm.quota_snapshot:
bucket = pvm.quota_snapshot.get_bucket_for_model(
pvm.preferred_models[0] if pvm.preferred_models else "default"
)
if bucket:
quota_st = bucket.status
elif pvm.health_state == STATUS_QUOTA_EXHAUSTED:
quota_st = "exhausted"
nodes.append(PipelineNode( nodes.append(PipelineNode(
profile_id=pid, profile_id=pid,
@ -705,6 +753,9 @@ class UnifiedHealthService:
status_label_ru=pvm.health_label_ru, status_label_ru=pvm.health_label_ru,
is_active=is_act, is_active=is_act,
cooldown_remaining_sec=pvm.cooldown_remaining_sec, cooldown_remaining_sec=pvm.cooldown_remaining_sec,
account_identity=pvm.account_identity,
quota_status=quota_st,
failover_reason=failover_reason,
)) ))
pipelines[rname] = RolePipeline( pipelines[rname] = RolePipeline(

View file

@ -156,3 +156,60 @@ def test_antigravity_claude_vs_gemini_quota_bucket_isolation():
assert snap.is_model_available("claude-3-7-sonnet") is False assert snap.is_model_available("claude-3-7-sonnet") is False
assert snap.is_model_available("gemini-2.5-pro") is True assert snap.is_model_available("gemini-2.5-pro") is True
@pytest.mark.unit
def test_route_and_agent_delta_events():
"""Verify apply_delta_route_changed publishes EVENT_ROUTING_UPDATED and EVENT_AGENT_UPDATED."""
bus = EventBus.get()
store = HubStateStore.get()
route_events = []
agent_events = []
def _on_route(name, payload):
route_events.append(payload)
def _on_agent(name, payload):
agent_events.append(payload)
bus.subscribe(EVENT_ROUTING_UPDATED, _on_route)
bus.subscribe("AGENT_UPDATED", _on_agent)
try:
store.apply_delta_route_changed("coder-primary", "ag-w1", failover_reason="Testing failover")
assert len(route_events) >= 1
assert route_events[-1]["role_id"] == "coder-primary"
assert route_events[-1]["failover_reason"] == "Testing failover"
assert "generation" in route_events[-1]
assert "seq" in route_events[-1]
assert len(agent_events) >= 1
assert agent_events[-1]["role_id"] == "coder-primary"
assert agent_events[-1]["agent"].role_id == "coder-primary"
finally:
bus.unsubscribe(EVENT_ROUTING_UPDATED, _on_route)
bus.unsubscribe("AGENT_UPDATED", _on_agent)
@pytest.mark.unit
def test_plan_source_and_pipeline_node_enrichment():
"""Verify ProfileViewModel.plan_source and PipelineNode fields (account_identity, failover_reason)."""
service = UnifiedHealthService.get()
snap = HubStateStore.get().refresh(force_scan=False)
for p in snap.all_profiles.values():
assert hasattr(p, "plan_source")
assert p.plan_source in ("provider_api", "jwt_claim", "provider_auth", "inferred", "unknown")
for agent in snap.agents:
assert hasattr(agent, "active_quota_status")
assert hasattr(agent, "active_quota_label")
assert hasattr(agent, "session_id")
for pipeline in snap.routing.values():
for node in pipeline.nodes:
assert hasattr(node, "account_identity")
assert hasattr(node, "quota_status")
assert hasattr(node, "failover_reason")