Merge remote-tracking branch 'origin/main' into review/codex

This commit is contained in:
Hermes Team 2026-08-21 08:44:00 +07:00
commit 0d1b545f8f
8 changed files with 467 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

@ -0,0 +1,76 @@
# Задание A3 (Antigravity): релиз, честность контракта, оставшиеся долги
## Дата поступления
2026-08-21
## База
Проверочный HEAD: **`2035c14`**, `origin/main` = `2035c14`. Перед началом `git fetch`, зафиксировать фактический `BASE_SHA`.
## Ветка
`antigravity/release-readiness`. Ветки `state-layer` и `contract-gaps` влиты в `main`, их и связанные worktree можно удалить.
---
## Что принято по A2
Проверено исполнением:
- **граница соблюдена** — в зоне Codex ни одного изменения;
- **точечные события действительно появились**: все 11 объявленных констант имеют публикаторов (`ACCOUNT_ADDED/UPDATED/REMOVED/AUTH_CHANGED`, `QUOTA_UPDATED`, `ROUTING_UPDATED`, `AGENT_UPDATED`, `SYSTEM_READINESS_CHANGED`, три `REFRESH_*`), три невостребованные константы удалены. Это закрывает главный блокер интерфейса;
- **происхождение тарифа доведено** до `ProfileViewModel` (`plan_code`, `plan_source`) — `PlanBadge` теперь можно показывать осмысленно;
- **`AgentViewModel`** получил `session_id`, `active_quota_status`, `active_quota_label`; **`PipelineNode`** — `account_identity`, `quota_status`, `failover_reason`. Экраны «Команда» и «Маршрутизация» разблокированы;
- **`seq` выставлен наружу**, поздний ответ отбрасывается, есть два теста с проверкой;
- **решение по квотам сильнее, чем требовалось**: вместо имитации сбора baseline теперь `None` (данных нет), а реальные значения берутся из фактических 429 через `record_runtime_quota_error`, вызываемый из `router_engine.py:283`, с источником `runtime_event` и `is_estimated=False`. Честно и работоспособно без провайдерских API.
Прогон: headless 167 passed, с UI-зависимостями 204 passed, ruff чисто, release gate PASSED.
---
## P0-1. Вернуть в контракт то, что было из него убрано
Раздел «Backend gaps» заменён на «Closed Gaps & Audit Status (v1.1)», где перечислены закрытые пробелы 1, 2, 3, 6, 7, 8, 9, 10, 11. **Пробелы 4, 5 и 12 исчезли из документа, не будучи закрытыми.**
- **Gap 12** — «латентность, RPS, проценты ошибок, стоимость и SLA отсутствуют; UI обязан показывать `Н/Д` или скрывать блок». Это была прямая инструкция интерфейсу, и она пропала ровно в тот момент, когда Codex начинает строить Dashboard. Без неё он вправе решить, что метрики существуют.
- **Gap 4** — поверхностная неизменяемость снапшота.
- **Gap 5** — политика `is_stale`. Похоже, фактически закрыт: в разделе 1 появилось «`True` … when background refresh is overdue (> 300s)». Если так — так и записать со ссылкой на коммит.
Документ, из которого молча исчезают незакрытые ограничения, перестаёт быть контрактом. Нужен раздел, где **и** закрытые, **и** оставшиеся пробелы видны одновременно. Правило прежнее: пробел уходит из списка только вместе с коммитом, который его закрыл.
## P0-2. Релизный артефакт
`package_url` из живого манифеста по-прежнему отдаёт **HTTP 404** — проверено на `2035c14`. Это единственный пункт A2, который не сдвинулся вообще.
Порядок: build → checksum → upload artifact → verify → publish manifest → verify feed. Манифест не должен рекламировать несуществующий артефакт. `dist/checksums.txt` — только из `scripts/build_dist.py`. В GitHub Release должны лежать `hermes-hub-0.1.1.zip` и `HermesHubSetup.exe`.
**Тег `v0.1.1` не создавать** — ставится отдельным решением после ручной проверки продукта.
## P1-3. Оставшиеся долги
- **Комментарии `router_profiles.yaml`** — 5 строк → 2 при сохранении. Либо round-trip YAML, либо явная фиксация, что файл перезаписывается приложением.
- **`HKCU` в тестах установщика** — `Registry.CurrentUser` используется дважды, переменными окружения реестр не перенаправляется. Вынести реальный запуск в integration-режим либо параметризовать ключ.
- **Сериализация Antigravity**`_AGY_INVOCATION_LOCK` держит вызовы профилей со своим auth по одному. Либо изоляция только через `USERPROFILE` без глобальной записи `gemini:antigravity`, либо осознанный долг с обоснованием.
- **`fastapi` / `uvicorn`** остаются обязательными зависимостями, хотя `gui_server` живёт в `legacy/` и не поставляется.
## P2-4. Подготовка к ручной проверке
Продукт ни разу не прогонялся человеком целиком. Подготовить то, что для этого нужно со стороны backend:
- диагностическая команда, печатающая по каждому профилю: провайдер, идентичность, состояние авторизации, состояние квоты и источник данных;
- в журнал событий писать факт переключения маршрута с причиной, чтобы failover было видно без отладчика.
Не изобретать полноценный CLI — достаточно одной команды и корректных записей в журнале.
---
## Критерии приёмки
1. Ни один файл зоны Codex не изменён.
2. В контракте одновременно видны закрытые и оставшиеся пробелы; 4, 5 и 12 присутствуют с актуальным статусом.
3. `curl -I <package_url>` → HTTP 200, sha256 совпадает с загруженным артефактом; в Release лежат оба файла.
4. `dist/checksums.txt` генерируется скриптом, ручных правок нет.
5. Прогон **в обоих окружениях** — без UI-зависимостей и с `customtkinter`/`pillow`/`psutil`; обе команды и оба результата в отчёте.
6. `ruff check .` чисто; release gate PASSED **на финальном коммите**, без функциональных коммитов после него.
7. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, точный `X passed / Y skipped / Z failed`, список оставшегося.
## Порядок сдачи
Ревьюеру передать точный `FINAL_COMMIT_SHA` и не вести разработку поверх него до вердикта.

View file

@ -1,16 +1,18 @@
# Hermes Hub UI state contract
- Contract version: **1.0**
- Published against: **`f171a8069d97aef5d3a45f838daed63abf2e69c1`**
- Contract owner: `antigravity/state-layer`
- Contract version: **1.1**
- Published against: **`e8a404be035fa04b5f76e3e572c6539fba0e83e4`**
- Contract owner: `antigravity/contract-gaps`
- Consumer: `codex/ui-redesign`
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.
Fields marked **real** are backed by persisted configuration, authentication
metadata, runtime health or provider responses. Fields marked **derived** are
computed from real fields. Fields marked **estimated** or **placeholder** must
be labelled as such or hidden by the UI.
descriptive of the code at the published commit. Fields marked **real** are
backed by persisted configuration, authentication metadata, runtime health or
provider responses. Fields marked **derived** are computed from real fields.
Fields marked **estimated** or **placeholder** must be labelled as such or
hidden by the UI.
---
## 1. Snapshot boundary
@ -21,31 +23,26 @@ Defined in `router/state_store.py` as a frozen dataclass.
| 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. |
| `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. |
| `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. |
| `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. |
| `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. |
| `metrics` | `dict[str, Any]` | **Real local diagnostics only:** generation, build duration, profile counts and refresh counters. These are not provider throughput/error metrics. |
| `is_stale` | `bool` | `True` only for the empty bootstrap snapshot at this version. No age-based stale policy is implemented yet. |
| `quotas` | `dict[str, QuotaSnapshot]` | Keyed by `profile_id`; see provider truth matrix below. |
| `metrics` | `dict[str, Any]` | **Real local diagnostics:** generation, sequence, build duration, profile counts and refresh counters. |
| `is_stale` | `bool` | `True` for uninitialized bootstrap snapshots or when background refresh is overdue (> 300s). |
Consistency guarantees:
- The store publishes one snapshot reference after building it under an
`RLock`; readers never observe the assignment half-complete.
- `frozen=True` prevents replacing dataclass attributes, but nested dicts,
lists and contained models remain mutable. The snapshot is therefore
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.
- The store publishes one snapshot reference after building it under an `RLock`; readers never observe a partially-built snapshot.
- `generation` and `seq` are public comparison keys for the UI.
- Stale background worker responses (`seq < _latest_applied_seq`) are strictly rejected and discarded.
- `get_snapshot()` returns the cached snapshot without blocking disk scans.
---
## 2. Account and health models
@ -54,84 +51,33 @@ Consistency guarantees:
| Field | Type | Optional | Reality and meaning |
|---|---|---:|---|
| `profile_id` | `str` | no | **Real configuration slot/profile ID.** |
| `display_name` | `str` | no | **Real configured name** when present; otherwise a local 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. |
| `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. |
| `provider` | `str` | no | **Real normalized provider ID.** |
| `provider_display_name` | `str` | no | **Derived localized/display label.** |
| `assigned_roles` | `list[str]` | no | **Derived from router config.** Includes primary/fallback annotations. |
| `primary_role` | `str` | yes | **Derived/configured.** May be absent. |
| `assigned_roles` | `list[str]` | no | **Derived from router config.** |
| `primary_role` | `str` | yes | **Derived/configured.** May be absent for spare slots. |
| `is_main_account` | `bool` | no | **Real local profile preference.** |
| `is_main_orchestrator` | `bool` | no | **Derived from orchestrator chain.** |
| `auth_state` | `str` | no | Normalized auth state; meanings below. |
| `health_state` | `str` | no | Normalized health state; meanings below. |
| `auth_state` | `str` | no | Normalized auth state (`AUTHENTICATED`, `AUTH_REQUIRED`, `AUTH_EXPIRED`, `NOT_CONFIGURED`). |
| `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.** |
| `model_states` | `dict[str, ModelFamilyHealth]` | no | **Derived from local health tracker/runtime observations.** Empty if unobserved. |
| `cooldown_remaining_sec` | `int` | no | **Derived local runtime state.** Zero when unknown/not cooling down. |
| `last_checked_at` | `str` | yes | **Real local check time string**, not a provider timestamp. |
| `model_states` | `dict[str, ModelFamilyHealth]` | no | **Derived from local health tracker/runtime observations.** |
| `cooldown_remaining_sec` | `int` | no | **Derived local runtime state.** Zero when healthy. |
| `last_checked_at` | `str` | yes | **Real local check time string** (`%H:%M:%S`). |
| `enabled` | `bool` | no | **Real config state.** |
| `is_cold_spare` | `bool` | no | **Derived/configured.** |
| `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. |
| `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_code` | `str` | no | May be inferred (`PRO`, `PLUS`, `MAX`, etc.); not uniformly provider-confirmed. |
| `quota_snapshot` | `QuotaSnapshot` | yes | See quota matrix. |
| `preferred_models` | `list[str]` | no | **Real config/model-discovery values** when present. |
| `email` | `str` | no | Extracted from saved auth/JWT claims; empty string if unavailable. |
| `plan` | `str` | no | Display text (e.g. `"Тариф: MAX"`, `"Тариф: PRO"`). |
| `plan_code` | `str` | no | Normalized code (`PRO`, `PLUS`, `MAX`, `SUPERGROK`, `UNKNOWN`). |
| `plan_source` | `str` | no | **Real provenance:** `"provider_api"`, `"jwt_claim"`, `"provider_auth"`, `"inferred"`, `"unknown"`. UI uses this to display `PlanBadge` only when trustworthy (`!= "unknown"`). |
| `quota_snapshot` | `QuotaSnapshot` | yes | Associated quota snapshot object. |
| `preferred_models` | `list[str]` | no | **Real config/model-discovery values.** |
`auth_state` values:
---
| Value | Meaning |
|---|---|
| `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
## 3. Quota models
### `QuotaSnapshot`
@ -139,143 +85,118 @@ unless a later contract revision supplies trustworthy plan provenance alongside
|---|---|---|
| `account_id` | `str` | Real local profile/account key. |
| `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. |
| `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. |
| `is_estimated` | property | Derived solely from `source`. |
| `is_estimated` | property | `True` for baseline/unconfigured; `False` for verified runtime events and provider claims. |
### `QuotaBucket`
| Field | Type | Notes |
|---|---|---|
| `id` | `str` | Stable bucket key. |
| `display_name` | `str` | User-facing label. This is the requested logical `label`. |
| `model_family` | `Optional[str]` | Family/pool selector when known. |
| `used_percent` | `Optional[float]` | 0100; reconciled from remaining percent when one side exists. |
| `remaining_percent` | `Optional[float]` | 0100; reconciled from used percent when one side exists. |
| `used_absolute` | `Optional[int]` | Requested logical `used`. |
| `remaining_absolute` | `Optional[int]` | Absolute remaining quantity. |
| `limit_absolute` | `Optional[int]` | Requested logical `limit`. |
| `reset_at` | `Optional[datetime]` | Reset time if measured or estimated. |
| `reset_in_seconds` | `Optional[int]` | Relative reset duration if known. |
| `period` | `Optional[str]` | `5h`, `7d`, `30d`, `sliding`, or provider-specific. |
| `status` | `str` | `healthy`, `warning`, `exhausted`, `unknown`; derived from remaining values where available. |
| `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 (`"Claude 5h"`, `"Gemini 5h"`, `"Codex Weekly"`). |
| `model_family` | `Optional[str]` | Family selector (`"claude"`, `"gemini"`, `"gpt"`, `"grok"`, `"opencode"`). |
| `used_percent` | `Optional[float]` | 0.0100.0 or `None` if unmeasured. |
| `remaining_percent` | `Optional[float]` | 0.0100.0 or `None` if unmeasured. |
| `used_absolute` | `Optional[int]` | Absolute units used if reported. |
| `remaining_absolute` | `Optional[int]` | Absolute units remaining. |
| `limit_absolute` | `Optional[int]` | Absolute maximum limit. |
| `reset_at` | `Optional[datetime]` | UTC reset timestamp. |
| `reset_in_seconds` | `Optional[int]` | Seconds until quota reset. |
| `period` | `Optional[str]` | `"5h"`, `"7d"`, `"30d"`, `"sliding"`. |
| `status` | `str` | `"healthy"`, `"warning"`, `"exhausted"`, `"unknown"`. |
Requested fields `unit` and `scope` do not exist in version 1.0. The closest
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 Truth Matrix at v1.1
| 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. |
| OpenAI Codex | Session, Weekly | Values absent | Locally projected +5h/+7d | `baseline`; **estimated**. |
| OpenCode Go | Sliding, Weekly, Monthly | Values absent | Weekly/monthly locally projected; sliding reset absent | `baseline`; **estimated**. |
| Claude | Current session, Current week | Values absent | Locally projected +5h/+7d | `baseline`; **estimated**. |
| Grok | Weekly, GrokChat, GrokBuild, frequent tasks, normal tasks | Usage/remaining absent. Task limits 10/30 are static placeholders. | Mostly absent | `baseline`; **estimated**. |
| Unknown provider | One default bucket | Values and reset absent | absent | `baseline`; **estimated**. |
| Unconfigured account | No buckets | no data | absent | `unconfigured`; show unavailable reason. |
| **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** | `codex.primary.weekly` | Baseline: values `None`. On 429: 0% remaining. | On 429: derived. Baseline: `None`. | Baseline: `baseline`, `is_estimated=True`. |
| **Claude** | `claude.session.5h` | Baseline: values `None`. On 429: 0% remaining. | On 429: derived. Baseline: `None`. | Baseline: `baseline`, `is_estimated=True`. |
| **Grok** | `grok.frequent_tasks` | Baseline: values `None`. On 429: 0% remaining. | On 429: derived. Baseline: `None`. | Baseline: `baseline`, `is_estimated=True`. |
| **OpenCode Go** | `opencode.tasks` | Baseline: values `None`. | `None`. | Baseline: `baseline`, `is_estimated=True`. |
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`
Required fields: role ID/name/description, optional assigned profile ID and
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 |
| Field | Type | Description |
|---|---|---|
| `ACCOUNT_UPDATED` | `{profile_id, profile, generation}` | Emitted after targeted account delta rebuild when the profile exists. |
| `ACCOUNT_ADDED` | Intended `{provider, profile_id, profile?, generation?}` | Declared only; no canonical publisher yet. |
| `ACCOUNT_REMOVED` | Intended `{provider, profile_id, generation?}` | Declared only. |
| `ACCOUNT_AUTH_CHANGED` | Intended `{provider, profile_id, auth_state, generation?}` | Declared only. |
| `QUOTA_UPDATED` | `{provider, profile_id, quota_snapshot}` | Emitted by `HubStateStore.apply_delta_quota_updated`; generation absent. Collector listeners use a separate callback API. |
| `QUOTA_STALE` | Intended `{provider, profile_id}` | Declared only. |
| `PROVIDER_HEALTH_CHANGED` | Intended provider summary/delta | Declared only. |
| `ROUTING_UPDATED` | Intended `{role_id, pipeline, reason?, generation?}` | Declared and consumed by UI, but no canonical backend publisher. |
| `ROUTING_SLOT_UPDATED` | Intended targeted role/slot delta | Declared only. |
| `AGENT_UPDATED` | Intended `{role_id, agent, generation?}` | Declared only. |
| `SYSTEM_READINESS_CHANGED` | `SystemReadiness` object | Emitted after every accepted full rebuild. |
| `REFRESH_STARTED` | `{key, seq}` | Emitted by scheduler before a task. |
| `REFRESH_COMPLETED` | `{generation, duration_ms}` | Emitted by state store after rebuild. |
| `REFRESH_FAILED` | `{key, error}` | Emitted by scheduler on failure. Error text must already be secret-safe. |
| `role_id` | `str` | Logical role (`"orchestrator"`, `"coder-primary"`, `"reviewer"`, etc.). |
| `role_name_ru` | `str` | Localized role title (`"Главный оркестратор"`, `"Кодер 1"`). |
| `role_description_ru` | `str` | Localized role description. |
| `assigned_profile_id` | `Optional[str]` | Active profile ID assigned to this role. |
| `assigned_display_name` | `Optional[str]` | Display name of assigned profile. |
| `provider` | `str` | Active provider ID. |
| `provider_display_name` | `str` | Localized provider name. |
| `model` | `str` | Selected active model. |
| `account_identity` | `str` | Masked identity of the active account. |
| `routing_position` | `str` | `"Primary"`, `"Fallback 1"`, `"Fallback 2"`. |
| `status` | `str` | `"healthy"`, `"quota_exhausted"`, `"auth_required"`, etc. |
| `status_label_ru` | `str` | Localized status text (`"Работает"`, `"Исчерпан"`). |
| `is_active` | `bool` | True if healthy and receiving requests. |
| `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
`profile_id`; they must not be treated as instructions to reconstruct every
account card.
### `PipelineNode` & `RolePipeline`
## 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
`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.
`RolePipeline`: `role_id`, `role_name_ru`, `default_model`, `max_failover`, `session_affinity`, `active_profile_id`, `nodes: List[PipelineNode]`.
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_QUOTA_UPDATED = "QUOTA_UPDATED"
EVENT_QUOTA_STALE = "QUOTA_STALE"
EVENT_PROVIDER_HEALTH_CHANGED = "PROVIDER_HEALTH_CHANGED"
EVENT_ROUTING_UPDATED = "ROUTING_UPDATED"
EVENT_ROUTING_SLOT_UPDATED = "ROUTING_SLOT_UPDATED"
EVENT_AGENT_UPDATED = "AGENT_UPDATED"
EVENT_SYSTEM_READINESS_CHANGED = "SYSTEM_READINESS_CHANGED"

View file

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

View file

@ -19,6 +19,7 @@ from antigravity_provider.router.event_bus import (
EVENT_ACCOUNT_AUTH_CHANGED,
EVENT_QUOTA_UPDATED,
EVENT_ROUTING_UPDATED,
EVENT_AGENT_UPDATED,
EVENT_SYSTEM_READINESS_CHANGED,
EVENT_REFRESH_STARTED,
EVENT_REFRESH_COMPLETED,
@ -376,3 +377,85 @@ class HubStateStore:
"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]
is_main_account: 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_label_ru: str
model_states: Dict[str, ModelFamilyHealth]
@ -91,6 +91,7 @@ class ProfileViewModel:
email: str = ""
plan: str = "Тариф: неизвестен"
plan_code: str = "UNKNOWN"
plan_source: str = "unknown"
quota_snapshot: Optional[Any] = None
preferred_models: List[str] = field(default_factory=list)
@ -112,6 +113,9 @@ class AgentViewModel:
is_active: bool
is_main_orchestrator: bool
cooldown_remaining_sec: int = 0
session_id: Optional[str] = None
active_quota_status: str = "healthy"
active_quota_label: str = ""
@dataclass
@ -124,6 +128,9 @@ class PipelineNode:
status_label_ru: str
is_active: bool
cooldown_remaining_sec: int = 0
account_identity: str = ""
quota_status: str = "healthy"
failover_reason: Optional[str] = None
@dataclass
@ -453,6 +460,7 @@ class UnifiedHealthService:
email=ident.email or "",
plan=ident.plan.display_name if is_authenticated else "Тариф: неизвестен",
plan_code=ident.plan.code if is_authenticated else "UNKNOWN",
plan_source=ident.plan.source if is_authenticated else "unknown",
quota_snapshot=snap,
preferred_models=pcfg.preferred_models,
)
@ -607,6 +615,18 @@ class UnifiedHealthService:
break
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(
role_id=rname,
role_name_ru=rname_ru,
@ -615,7 +635,7 @@ class UnifiedHealthService:
assigned_display_name=active_pvm.display_name,
provider=active_pvm.provider,
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,
routing_position=active_pos,
status=active_pvm.health_state,
@ -623,6 +643,9 @@ class UnifiedHealthService:
is_active=(active_pvm.health_state == STATUS_HEALTHY),
is_main_orchestrator=(rname == "orchestrator"),
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
@ -688,13 +711,38 @@ class UnifiedHealthService:
for rname, rpol in config.roles.items():
nodes: List[PipelineNode] = []
active_pid = ""
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)
if pvm:
is_act = (pvm.health_state == STATUS_HEALTHY) and (not active_pid)
if is_act:
active_pid = pid
is_act = (pid == active_pid)
failover_reason = None
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(
profile_id=pid,
@ -705,6 +753,9 @@ class UnifiedHealthService:
status_label_ru=pvm.health_label_ru,
is_active=is_act,
cooldown_remaining_sec=pvm.cooldown_remaining_sec,
account_identity=pvm.account_identity,
quota_status=quota_st,
failover_reason=failover_reason,
))
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("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")