diff --git a/agents/done/2026-08-21-A2-antigravity-contract-gaps.md b/agents/done/2026-08-21-A2-antigravity-contract-gaps.md new file mode 100644 index 0000000..695c8c9 --- /dev/null +++ b/agents/done/2026-08-21-A2-antigravity-contract-gaps.md @@ -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`. diff --git a/agents/inbox/2026-08-21-A3-antigravity-release-and-honesty.md b/agents/inbox/2026-08-21-A3-antigravity-release-and-honesty.md new file mode 100644 index 0000000..fb32a41 --- /dev/null +++ b/agents/inbox/2026-08-21-A3-antigravity-release-and-honesty.md @@ -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 ` → 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` и не вести разработку поверх него до вердикта. diff --git a/docs/UI_STATE_CONTRACT.md b/docs/UI_STATE_CONTRACT.md index 74c2fce..501d82d 100644 --- a/docs/UI_STATE_CONTRACT.md +++ b/docs/UI_STATE_CONTRACT.md @@ -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]` | 0–100; reconciled from remaining percent when one side exists. | -| `remaining_percent` | `Optional[float]` | 0–100; 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.0–100.0 or `None` if unmeasured. | +| `remaining_percent` | `Optional[float]` | 0.0–100.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. | diff --git a/src/antigravity_provider/router/event_bus.py b/src/antigravity_provider/router/event_bus.py index 8bbc195..c624889 100644 --- a/src/antigravity_provider/router/event_bus.py +++ b/src/antigravity_provider/router/event_bus.py @@ -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" diff --git a/src/antigravity_provider/router/scheduler.py b/src/antigravity_provider/router/scheduler.py index da4064b..b8aed37 100644 --- a/src/antigravity_provider/router/scheduler.py +++ b/src/antigravity_provider/router/scheduler.py @@ -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() diff --git a/src/antigravity_provider/router/state_store.py b/src/antigravity_provider/router/state_store.py index 6d8d4b5..f923bf1 100644 --- a/src/antigravity_provider/router/state_store.py +++ b/src/antigravity_provider/router/state_store.py @@ -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, + }, + ) diff --git a/src/antigravity_provider/router/unified_health.py b/src/antigravity_provider/router/unified_health.py index 3bb6a8d..df24b4a 100644 --- a/src/antigravity_provider/router/unified_health.py +++ b/src/antigravity_provider/router/unified_health.py @@ -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( diff --git a/tests/test_state_layer_and_event_driven_quota.py b/tests/test_state_layer_and_event_driven_quota.py index 82dab22..458cfbc 100644 --- a/tests/test_state_layer_and_event_driven_quota.py +++ b/tests/test_state_layer_and_event_driven_quota.py @@ -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") +