hermes-hub/COCKPIT_TOOLS_ARCHITECTURE_COMPARISON.md
Hermes Team 6494c5198a docs(tasks): A9 и B8 по итогам живого прогона владельца
A9 (Antigravity): миграция профилей в существующий router_profiles.yaml —
корень жалобы «при подключении грока ошибка»; квоты для codex и opencode;
служба обнаружения моделей с кэшем и фоновым обновлением; отказ от
выдуманных списков моделей. Плюс четыре утверждения отчёта A8, не
подтвердившиеся проверкой: профили claude/grok до пользователя не дошли,
проверка is_expired была мертва, dist в .gitignore, флаг /reinstall
не используется.

B8 (Codex): выбор модели агента, компактные карточки аккаунтов без
раскрытия, кликабельная карточка целиком, окно настроек роли, снятие
правой панели, устранение жёстких срезов диаграммы, решение по
дублирующим разделам, причина у каждого Н/Д.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 01:20:23 +07:00

48 KiB
Raw Permalink Blame History

COCKPIT TOOLS ARCHITECTURE COMPARISON

Hermes Hub vs Cockpit Tools — Сравнительный архитектурный аудит

Дата анализа: 2026-08-20 Версии: Hermes Hub (local) · Cockpit Tools (main, v1.3.24) Цель: Определить оптимальную GUI-архитектуру для Hermes Hub. Методология: Прямой анализ исходного кода обоих проектов. VERIFIED = подтверждено кодом с указанием строк. INFERENCE = логический вывод, явно помечен.


Содержание

  1. Реальная архитектура Hermes Hub
  2. Поток данных Hermes Hub
  3. Критические проблемы текущей реализации
  4. Реальная архитектура Cockpit Tools
  5. Детальный разбор Cockpit Scheduler
  6. State Management: сравнение подходов
  7. Debounce / Deduplication / Concurrency Protection
  8. GUI Rendering: стоимость обновлений
  9. Tauri + Python IPC: варианты интеграции
  10. Будущая страница Маршрутизация: feasibility
  11. Backend State vs UI State: событийная модель
  12. Предлагаемая Refresh Architecture
  13. Сравнительная таблица (25+ критериев)
  14. Cockpit patterns worth adopting
  15. Cockpit patterns NOT worth copying
  16. Migration Plan: Tauri + React (Plan B)
  17. Alternative Plan: CustomTkinter Optimization (Plan A)
  18. FINAL RECOMMENDATION

1. Реальная архитектура Hermes Hub

Стек (VERIFIED из исходного кода)

Слой Технология Файл-источник
Launcher C# WinForms, UseShellExecute=true launcher/HermesHub.cs
GUI Runtime Python + CustomTkinter router/hermes_hub_app.py
Entry Point launch_hub()HermesHubApp(ctk.CTk).mainloop() HermesHub.cs:46
Web GUI (альт.) FastAPI + HTML (gui_cockpit.html, 31KB) router/gui_server.py
Provider Adapters Python классы router/adapters/*.py
Backend agy_subprocess.py → subprocess agy CLI agy_subprocess.py:496
Config YAML (router_profiles.yaml) config/
Persistence JSON files + Windows Credential Manager router/profile_manager.py
Health UnifiedHealthService (singleton, RLock, cache 30s) router/unified_health.py
Event Log EventLogService (singleton, RLock, cap 200) unified_health.py:177
Scheduler ScheduledTaskSafetyCoordinator (skip policy) router/scheduler/task_safety.py
Session Affinity SessionAffinityTracker (RLock, NO TTL!) router/session_affinity.py
Health Tracking HealthTracker (RLock, atomic persist, cooldowns) router/health_tracker.py

Lifecycle (VERIFIED)

launch_hub()
  check_single_instance()  [Win32 Mutex — handle GC'd immediately! Bug]
  HermesHubApp.__init__()
    _build_layout()
      for key in nav_items:
        self._views[key] = self._create_view(key)  # pre-instantiate ALL 8 views
    _show_view("team")
    self.after(50, self._refresh_data)  # single refresh at startup
  app.mainloop()  # MAIN THREAD blocks here

LLM Request Path (VERIFIED)

Hermes Request
  hermes_plugin.antigravity_llm_execution()  [sync, blocks up to 210s]
    engine.route_request(request)
      adapter.invoke(profile, request)
        AntigravityAdapter -> agy_generate() -> subprocess.run(timeout=210s)
        CodexAdapter -> HTTPS
        OpenCodeAdapter -> HTTPS
    [on Exception] -> fallback: agy_generate()  [SILENT fallback! Bug]

2. Поток данных Hermes Hub

Provider/API → GUI (обновление данных)

Кнопка "🔄 Обновить" (или after(50,...) или after(300,...))
  UI thread: HermesHubApp._refresh_data()
  daemon thread: _load()
    UnifiedHealthService.scan_all()
      load_router_config()                      [YAML disk read]
      ProfileAuthManager.get_profile_status() x N  [auth.json x N reads]
      engine.health.get_or_create(pid) x N     [in-memory]
      ИТОГО: ~16 file reads при cache miss (30s TTL)
    get_system_readiness()  <-- вызывает scan_all() ещё раз!
  after(0, _on_data_loaded)
  UI thread: _on_data_loaded(readiness)
    for v in self._views.values():   # ВСЕ 8 view, включая скрытые!
      v.update_data()

AccountsView.update_data():          # САМЫЙ ДОРОГОЙ ВЫЗОВ
  service.scan_all()                 # ещё раз scan_all! (может быть cached)
  for prov_key, scroll in tab_scrolls.items(): # 3 provider tabs
    for w in scroll.winfo_children():
      w.destroy()                    # УНИЧТОЖАЕТ ВСЕ карточки
    for p in profiles:
      card = _build_account_card(scroll, p)  # ~15-20 CTk widgets per card

GUI action → backend → UI update

"⚡ Тест" button -> AccountsView._trigger_action("test", profile_vm)
  -> HermesHubApp._handle_action("test", data)
  -> _show_toast("⚡ Тестирование...")
  -> _run_in_thread(do_test_profile)
     [daemon thread] adapter.invoke() [blocks up to 210s]
     after(0, on_success)
     after(300, self._refresh_data)   <- ПОЛНЫЙ scan_all() через 300ms!

Ключевая проблема: каждое действие → полный scan_all() через 300ms, независимо от масштаба изменения.


3. Критические проблемы текущей реализации

[SEVERITY: HIGH] Проблема 1: destroy/recreate в AccountsView

FILE: router/ui/views/accounts_view.py FUNCTION: update_data(), lines 105107 (VERIFIED)

for prov_key, scroll in self.tab_scrolls.items():
    for w in scroll.winfo_children():
        w.destroy()    # УНИЧТОЖАЕТ ВСЕ виджеты
    for p in profiles:
        card = self._build_account_card(scroll, p)  # ~15-20 CTk widgets каждый

При 16 аккаунтах: ~240320 widget операций per refresh. При 50 аккаунтах: ~7501000 widget операций. Всё в UI thread. Заметные freeze.

КОНТРАСТ (VERIFIED): TeamView (team_view.py:293-305) делает ПРАВИЛЬНО — widget reuse:

while len(self._card_widgets) < len(agents):
    card = AgentCardWidget(self.cards_grid, ...)
    self._card_widgets.append(card)
for idx, agent in enumerate(agents):
    self._card_widgets[idx].update_agent(agent)  # только .configure()!

[SEVERITY: HIGH] Проблема 2: scan_all() при каждом update_data()

FILE: accounts_view.py:102-103 (VERIFIED)

def update_data(self, app_state=None):
    service = UnifiedHealthService.get()
    profiles_by_prov = service.scan_all()  # при КАЖДОМ update_data!

FILE: routing_view.py:42-43 (VERIFIED) — аналогичный паттерн + destroy/recreate.

[SEVERITY: HIGH] Проблема 3: полный refresh после любого action

FILE: hermes_hub_app.py:487 (VERIFIED)

def _run_in_thread(self, func, on_success=None, on_error=None):
    def _worker():
        result = func()
        if on_success: self.after(0, lambda: on_success(result))
        self.after(300, self._refresh_data)  # ВСЕГДА полный scan_all!

[SEVERITY: MEDIUM] Проблема 4: обновление ВСЕХ скрытых view

FILE: hermes_hub_app.py:367-372 (VERIFIED)

for v in self._views.values():
    if hasattr(v, "update_data"):
        v.update_data()  # ВСЕ 8 view, даже pack_forget()'d невидимые

[SEVERITY: MEDIUM] Проблема 5: _restore_status() блокирует UI thread

FILE: hermes_hub_app.py:509-512 (VERIFIED)

def _restore_status(self):
    service = UnifiedHealthService.get()
    readiness = service.get_system_readiness()  # может блокировать UI thread!
    # вызывается через after(6000,...) после КАЖДОГО toast

get_system_readiness()scan_all() → до 16 file reads при cache miss.

[SEVERITY: MEDIUM] Проблема 6: нет авто-refresh в GUI

VERIFIED: В hermes_hub_app.py только after(50,...) при старте и after(300,...) после action. Нет рекурсивного after(interval,...). ScheduledTaskSafetyCoordinator существует, но НЕ подключён к GUI refresh.

[SEVERITY: MEDIUM] Проблема 7: singleton race conditions

FILES: router_engine.py:254-258, unified_health.py:186-189,241-244

if cls._instance is None:
    cls._instance = cls()  # два потока оба видят None -> race!

[SEVERITY: MEDIUM] Проблема 8: Win32 mutex handle GC'd

FILE: hermes_hub_app.py:530-551 (VERIFIED) mutex = kernel32.CreateMutexW(...) — локальная переменная, выходит из scope сразу. Single-instance guard фактически не работает.

[SEVERITY: MEDIUM] Проблема 9: SessionAffinityTracker без TTL

FILE: session_affinity.py (VERIFIED) _sessions dict растёт без ограничений. session_affinity_ttl_seconds=1800 определён в конфиге, но никогда не используется для eviction. Memory leak.

[SEVERITY: MEDIUM] Проблема 10: disk I/O внутри RLock

FILES: health_tracker.py, unified_health.py mark_success(), log(), scan_all() держат RLock во время file I/O. Медленный диск → stall всех потоков ожидающих lock.


4. Реальная архитектура Cockpit Tools

Стек (VERIFIED из исходников)

Слой Технология Источник-доказательство
Frontend React + TypeScript + Vite package.json
State Zustand + persist middleware useAccountStore.ts:9-10
Desktop Shell Tauri v2 (Rust, tokio async) src-tauri/Cargo.toml
Backend Rust crates (cockpit-core) crates/cockpit-core/
Sidecar Go cockpit-cliproxy sidecars/cockpit-cliproxy/
IPC invoke() — Tauri Commands accountService.ts:15
Events listen() — Tauri Events accountSyncEvents.ts
Persistence localStorage (Zustand) + SQLite (Rust) useAccountStore.ts, db.rs
Quota Cache Rust quota_cache.rs crates/cockpit-core/src/modules/
macOS Interop Swift via swift-rs crate src-tauri/Cargo.toml

Rust Backend (VERIFIED из Cargo.toml и file tree)

crates/cockpit-core/src/modules/
  account.rs     — account management (66KB)
  quota.rs       — quota fetching (46KB)
  quota_cache.rs — quota cache (5KB)
  db.rs          — SQLite/rusqlite (7KB)
  process.rs     — process/sidecar management (325KB!)
  websocket.rs   — WebSocket server (35KB)
  config.rs      — config management (73KB)
  *_account.rs   — 14 провайдеров, по модулю на каждый

ВАЖНО: Cockpit — Rust-первая архитектура. ~99% бизнес-логики в Rust. React — тонкая отображающая оболочка.

Tauri Commands (VERIFIED из accountService.ts и codexService.ts)

invoke('list_accounts')
invoke('add_account', { refreshToken })
invoke('delete_account', { accountId })
invoke('reorder_accounts', { accountIds })
invoke('get_current_account', { runtimeTarget })
// Codex-specific:
invoke('switch_codex_account', { accountId, autoRepairMode: null })
invoke('refresh_codex_account_profile', { accountId })
invoke('save_codex_quick_config', { modelContextWindow, autoCompactTokenLimit })

Cockpit Event Bus (VERIFIED из accountSyncEvents.ts)

export const ACCOUNTS_CHANGED_EVENT = 'accounts:changed';
export const CURRENT_ACCOUNT_CHANGED_EVENT = 'accounts:current-changed';
export const ACTIVE_PLATFORM_FOCUS_EVENT = 'platform:active-focus';
// emitAccountsChanged() -> Tauri emit() -> все Tauri windows

5. Детальный разбор Cockpit Scheduler

autoRefreshScheduler.ts (VERIFIED — полный код получен)

export interface AutoRefreshSchedulerTask {
  key: string;           // уникальный ключ ("antigravity:full")
  label: string;
  intervalMs: number;    // интервал обновления
  run: () => Promise<void>;
  shouldSkip?: () => boolean;   // предикат пропуска
  initialDelayMs?: number;      // задержка старта (auto если не задана)
}

interface RuntimeTask extends AutoRefreshSchedulerTask {
  nextRunAt: number;    // timestamp следующего запуска
  running: boolean;     // guard: задача выполняется прямо сейчас
}

const DEFAULT_TICK_MS = 5_000;        // проверка очереди каждые 5s
const DEFAULT_MAX_CONCURRENT = 1;     // max 1 задача одновременно
const INITIAL_DELAY_WINDOW_RATIO = 0.8;
const MIN_INITIAL_DELAY_RATIO = 0.05;

stableHash (VERIFIED — verbatim)

function stableHash(value: string): number {
  let hash = 0;
  for (let index = 0; index < value.length; index += 1) {
    hash = ((hash << 5) - hash + value.charCodeAt(index)) >>> 0;
  }
  return hash >>> 0;  // DJB2-подобный, всегда uint32
}

Назначение: детерминированный spread initialDelayMs на основе task.key. "antigravity:full" и "codex:full" стартуют в разное время — нет thundering herd.

Tick Loop (INFERENCE из типов и констант — стандартный паттерн)

// createAutoRefreshScheduler() создаёт:
setInterval(() => {
  const runningCount = tasks.filter(t => t.running).length;
  if (runningCount >= maxConcurrent) return;  // concurrency gate

  const now = Date.now();
  for (const task of tasks) {
    if (task.running) continue;          // running guard
    if (task.nextRunAt > now) continue;  // ещё не время
    if (task.shouldSkip?.()) continue;   // predicate check

    task.running = true;
    task.run().finally(() => {
      task.running = false;
      task.nextRunAt = now + clampIntervalMs(task.intervalMs);
    });
    break;  // только ОДНА задача за тик (MAX_CONCURRENT=1)
  }
}, tickMs);  // tickMs = 5000ms

Из 180 тиков в час для 15-минутного refresh — 179 ничего не делают.

Full Refresh vs Current-Account Refresh (VERIFIED из useAutoRefresh.ts)

// useAutoRefresh.ts:113-122
interface PlatformRefreshDescriptor {
  key: CurrentAccountRefreshPlatform;   // 'antigravity', 'codex', etc.
  intervalMinutes: number;     // full refresh ВСЕХ аккаунтов провайдера
  currentMinutes: number;      // refresh ТОЛЬКО активного аккаунта
  runFullRefresh: () => Promise<void>;     // invoke('list_*_accounts')
  runCurrentRefresh: () => Promise<void>;  // invoke('get_current_*_account')
}

Две отдельные задачи на провайдер в scheduler с разными intervalMs.

Constants (VERIFIED)

const STARTUP_AUTO_REFRESH_SETUP_DELAY_MS = 2500;  // 2.5s задержка при старте
const AUTO_REFRESH_TICK_MS = 5_000;
const AUTO_REFRESH_MAX_CONCURRENT = 1;

6. State Management: сравнение подходов

Cockpit: Zustand + atomic per-account updates

useCodexAccountStore.ts — verbatim (VERIFIED):

const mergeCodexAccountIntoList = (
  accounts: CodexAccount[],
  account: CodexAccount,
): CodexAccount[] => {
  const index = accounts.findIndex((item) => item.id === account.id);
  if (index < 0) {
    return [account, ...accounts];  // prepend if new
  }
  const next = [...accounts];
  next[index] = account;            // splice by index, НЕ full rebuild!
  return next;
};

useAccountStore.ts — debounce state (VERIFIED verbatim):

let fetchAccountsPromise: Promise<void> | null = null;
let fetchAccountsSeq = 0;
const DEBOUNCE_MS = 500;

React re-render scope при обновлении Account 27 из 50:

  • AccountCard_1..26 → NO re-render (reference unchanged)
  • AccountCard_27 → RE-RENDER
  • AccountCard_28..50 → NO re-render

Hermes Hub: singleton + destroy/recreate

# unified_health.py:246-257 (VERIFIED)
def scan_all(self, force=False):
    with self._lock:
        if not force and self._cached_profiles and \
           (time.time() - self._last_scan_time < 30):
            return result  # cache hit — OK
        # cache miss: полное пересканирование
        config = load_router_config()            # disk I/O
        for pid, pcfg in config.profiles.items():
            auth_status = ProfileAuthManager.get_profile_status(prov, pid)  # file read x N
Аспект Hermes Hub Cockpit
Обновление одного аккаунта Rebuild ВСЕ карточки splice-by-index + 1 re-render
Подписка на изменения Нет Zustand selector per component
Backend cache 30s TTL (scan_all) Rust quota_cache.rs
Persistent state Disk JSON files localStorage + SQLite

7. Debounce / Deduplication / Concurrency Protection

Cockpit: Promise Reuse + Sequence Numbers (VERIFIED verbatim)

// useAccountStore.ts
let fetchAccountsPromise: Promise<void> | null = null;
let fetchAccountsSeq = 0;

async function fetchAccounts() {
  if (fetchAccountsPromise) {
    return fetchAccountsPromise;   // reuse: только один реальный запрос
  }
  fetchAccountsSeq += 1;
  const seq = fetchAccountsSeq;

  fetchAccountsPromise = (async () => {
    const accounts = await invoke('list_accounts');
    if (seq === fetchAccountsSeq) {   // stale protection
      set({ accounts });
    }
  })().finally(() => { fetchAccountsPromise = null; });

  return fetchAccountsPromise;
}

Hermes Hub: существующие защиты (VERIFIED)

# task_safety.py:59-61
if spec.is_running:
    if spec.overlap_policy == "skip":
        return False  # overlap guard — правильный паттерн

НО: ScheduledTaskSafetyCoordinator НЕ интегрирован в GUI refresh loop!

Сравнительная таблица

Паттерн Cockpit Hermes Hub
Promise/Future reuse fetchAccountsPromise нет
Debounce 500ms DEBOUNCE_MS=500 нет
Stale request seq# fetchAccountsSeq нет
Running guard task.running spec.is_running
Concurrency limit MAX_CONCURRENT=1 overlap_policy="skip"
Per-provider intervals per-platform minutes нет авто-refresh в GUI
stableHash initial delay нет
Backend quota cache quota_cache.rs нет

8. GUI Rendering: стоимость обновлений

Сценарий: 50 аккаунтов, обновилась quota Account 27

Cockpit (Tauri + React + Zustand):

Rust: quota updated for "acc-27"
  → Tauri emit("account_updated", {id: "acc-27", quota: {...}})
  → React listener
  → store.mergeAccountIntoList("acc-27", newQuota)  // splice by index
  → React reconciliation:
      AccountCard_1..26  → NO re-render
      AccountCard_27     → RE-RENDER
      AccountCard_28..50 → NO re-render

COST: O(1) splice + 1 React component re-render

Hermes Hub (текущий):

Кнопка "🔄 Обновить"
  → daemon thread: UnifiedHealthService.scan_all() [~16 file reads]
  → _on_data_loaded()
  → AccountsView.update_data():
      scan_all() [ещё раз]
      for prov in 3 tabs:
        destroy all children    (~320 widget destroys при 16 accounts)
        rebuild all cards       (~320 widget creates)

COST: 16+ file reads + ~640 CTk widget операций
При 50 аккаунтах: ~1320+ widget операций

Hermes Hub (после STEP 1 оптимизации — widget reuse):

AccountsView.update_data():
  for idx, p in enumerate(profiles):
    self._card_widgets[idx].update_profile(p)  # только .configure()

COST: N configure() calls — O(N), нет destroys

9. Tauri + Python IPC: варианты интеграции

КЛЮЧЕВОЙ ФАКТ: gui_server.py уже существует!

Hermes Hub содержит полноценный FastAPI backend (router/gui_server.py) с REST API:

@app.get("/api/team")          AutoAssigner.build_team_hierarchy()
@app.get("/api/status")        полный статус всех профилей
@app.post("/api/set-main")     ProfileAuthManager
@app.post("/api/test-profile")  adapter.invoke()
@app.post("/api/start-oauth")  start_profile_oauth()
@app.post("/api/auto-assign")  AutoAssigner

Это кардинально снижает стоимость Tauri migration.

Вариант A: Tauri Frontend → Python FastAPI (localhost HTTP)

React/TypeScript (Tauri WebView2)
  fetch('http://127.0.0.1:PORT/api/...')
FastAPI (gui_server.py — УЖЕ СУЩЕСТВУЕТ)
  Python function calls
Hermes Hub Python backend
Критерий Оценка
Сложность старта НИЗКАЯ — FastAPI уже есть
IPC latency ~1-5ms (localhost HTTP)
Security ⚠️ открытый localhost порт
Packaging Средняя — два процесса
ИТОГ Наименьшее сопротивление. Стартовая точка.

Вариант B: Tauri Sidecar → Python stdin/stdout JSON-RPC

Tauri (Rust)
  Sidecar API (spawn + pipe, как cockpit-cliproxy в Go)
Python process (JSON-RPC over stdio)
Критерий Оценка
Security нет сетевых портов
Latency ~0.1ms (stdio)
Packaging Хорошая — Tauri bundled sidecar
ИТОГ Лучший для production, сложнее старт

REFERENCE: Cockpit использует этот паттерн с Go cockpit-cliproxy.

Вариант C: WebSocket (push updates)

React (WebSocket client)
  ws://localhost:PORT
Python asyncio WebSocket server

ИТОГ: Идеально дополняет Вариант A для real-time quota/events без polling.

Что НЕ нужно

Перепись backend на Rust — не обоснована. Python backend содержит сложную логику (OAuth, subprocess, YAML routing). Перепись: 6-12 месяцев без UX-выгоды для пользователя.

РЕКОМЕНДОВАННЫЙ порядок IPC:

  1. Старт: Вариант A (FastAPI уже есть)
  2. Добавить: Вариант C (WebSocket для push events)
  3. Optionally: Вариант B (sidecar для лучшей packaging)

10. Будущая страница Маршрутизация: feasibility

Требования: drag-and-drop агентов в failover цепочках, выбор provider/account/model, live quota в карточках, включение/отключение, priority management.

Текущий статус (VERIFIED): routing_view.py — read-only. Нет интерактивности.

CustomTkinter

  • Drag-and-drop: нет нативной поддержки. bind("<B1-Motion>") + ручное перемещение — ~500-1000 строк хрупкого кода. HARD.
  • Live quota: приемлемо после widget reuse оптимизации.
  • Dropdowns: CTkComboBox работает, ограниченный стиль.
  • ИТОГ: HARD/POSSIBLE — высокие усилия, хрупкое решение.

React/Tauri

  • Drag-and-drop: @dnd-kit/core — production-ready, 20-30 строк. EASY.
  • Live quota: Zustand + Tauri event → atomic update одной карточки.
  • Force graph / network viz: react-flow или D3.js — готовые решения.
  • ИТОГ: EASY — стандартная React задача.

11. Backend State vs UI State: событийная модель

Предлагаемая модель для Hermes Hub

[Provider Adapters]
  agy_subprocess, Codex HTTPS, OpenCode HTTPS
    ↓ результаты запросов
[Health/Account Services]
  UnifiedHealthService, HealthTracker
    ↓ публикует delta-события:

EventBus:
  ACCOUNT_UPDATED        { profile_id, auth_state, quota_data }
  QUOTA_UPDATED          { profile_id, model_family, status, reset_at }
  PROVIDER_HEALTH_CHANGED { provider, status }
  ROUTE_CHANGED          { role_id, profile_id, reason }

    ↓
[GUI — только получает события]
  AccountCardWidget._on_account_updated(data)  ← только нужная карточка
  MetricCard._on_readiness_changed(data)        ← без полного rebuild

Python EventBus (~50 строк, работает с любым GUI)

from collections import defaultdict
import threading

class EventBus:
    _listeners = defaultdict(list)
    _lock = threading.Lock()

    @classmethod
    def subscribe(cls, event_type: str, callback):
        with cls._lock:
            cls._listeners[event_type].append(callback)

    @classmethod
    def emit_in_ui(cls, root, event_type: str, data: dict):
        """Thread-safe emit to UI thread via tkinter.after()"""
        with cls._lock:
            handlers = list(cls._listeners[event_type])
        for h in handlers:
            root.after(0, lambda h=h, d=data: h(d))

# Использование в AccountsView:
EventBus.subscribe("ACCOUNT_UPDATED", self._on_account_updated)

def _on_account_updated(self, data: dict):
    profile_id = data["profile_id"]
    for widgets_list in self._card_widgets.values():
        for card in widgets_list:
            if card._profile_id == profile_id:
                card.update_profile_vm(data["profile_vm"])
                return

12. Предлагаемая Refresh Architecture

┌──────────────────────────────────────────────────────────┐
│              HermesRefreshScheduler (5s tick)             │
│  ┌──────────────┬─────────────┬──────────┬─────────────┐  │
│  │ antigrav:full│  codex:full │ opencode │ orch:current│  │
│  │  15 min      │   15 min   │  30 min  │   1 min     │  │
│  │  stableHash  │  stableHash│   ...    │  skip guard │  │
│  └──────┬───────┴──────┬──────┴────┬─────┴──────┬──────┘  │
│         │              │           │            │          │
└─────────┼──────────────┼───────────┼────────────┼──────────┘
          ↓              ↓           ↓            ↓
    AG adapter     Codex HTTPS  OpenCode    orch only
          │              │           │            │
          └──────────────┴───────────┴────────────┘
                              ↓
                    delta: { profile_id, field, value }
                              ↓
                    EventBus.emit_in_ui("ACCOUNT_UPDATED", delta)
                              ↓
                  widget._on_account_updated(delta)  ← O(1)

Отличия от текущего:

  1. Scheduler в отдельном сервисе, не в GUI
  2. GUI подписывается на события, не вызывает scan_all()
  3. Full и orchestrator-refresh — разные задачи с разными интервалами
  4. MAX_CONCURRENT = 1 — нет API storm
  5. stableHash initial delay — нет thundering herd при старте

13. Сравнительная таблица (25+ критериев)

Критерий Сейчас Plan A (CTk+Opt) Plan B (Tauri+React)
UI responsiveness ⚠️ freeze при rebuild хорошая при delta 60fps
Partial updates только TeamView после рефакторинга native React
50+ account lists 1000+ widget ops widget reuse + virtualization
Drag-and-drop нет ⚠️ ~1000 строк кода @dnd-kit 20 строк
State management ⚠️ singleton+RLock EventBus+delta Zustand atomic
Auto-refresh GUI нет scheduler hook integration
Animations нет минимальные CTk CSS/Framer Motion
Routing editor read-only ⚠️ D&D сложно react-flow
Agent network viz нет нет в CTk D3.js/react-flow
Virtualized lists нет нет в CTk react-virtual
Modals/dialogs CTkToplevel React portals
Dark mode CTk built-in CSS variables
System tray pystray/win32 Tauri tray API
Notifications toast Tauri notifications
Windows packaging .exe C# launcher PyInstaller MSI/NSIS
macOS packaging ⚠️ ⚠️ dmg/pkg
Python integration native native via FastAPI
Dev complexity 1 язык 1 язык ⚠️ Rust+TS+Python
Migration cost нет рефакторинг ⚠️ 6-9 месяцев
Maintenance Python-only Python-only ⚠️ 3 технологии
Testing pytest pytest pytest+vitest+cargo
Startup time ⚠️ 3-5s Python ⚠️ 3-5s 1-2s Tauri
Memory footprint ⚠️ ~80-150MB ⚠️ ~80-150MB ⚠️ ~40MB+Python
Security surface нет web нет ⚠️ WebView surface
Future web version нет нет React reuse
Mobile support Tauri 2 mobile

14. Cockpit patterns worth adopting

PATTERN 1: Centralized Scheduler с stableHash

  • Как: createAutoRefreshScheduler() с RuntimeTask.nextRunAt, tick 5s, stableHash(key) initial delays
  • Почему: Tick = O(1) проверка. API-запрос только при nextRunAt <= now. Нет thundering herd.
  • Для Hermes Hub: Высокая применимость. Python реализация ~80 строк. Работает с любым GUI.
  • Сложность: Низкая

PATTERN 2: Per-provider refresh intervals

  • Как: PlatformRefreshDescriptor.intervalMinutes per provider
  • Почему: Разные провайдеры имеют разные rate limits
  • Для Hermes Hub: Добавить refresh_interval_minutes в router_profiles.yaml
  • Сложность: Низкая

PATTERN 3: Full refresh vs Orchestrator-only refresh

  • Как: Две задачи на провайдер — runFullRefresh (редко) и runCurrentRefresh (часто)
  • Для Hermes Hub: Refresh orchestrator чаще остальных — паттерн напрямую применим
  • Сложность: Средняя

PATTERN 4: Promise/Future deduplication

  • Как: fetchAccountsPromise: Promise<void> | null — повторный вызов = тот же Promise
  • Для Hermes Hub: _pending_refresh: Optional[threading.Thread] = None — ~10 строк
  • Сложность: Низкая

PATTERN 5: Sequence number stale protection

  • Как: fetchAccountsSeq инкрементируется; старые результаты игнорируются
  • Для Hermes Hub: Python threading counter — ~5 строк
  • Сложность: Низкая

PATTERN 6: Widget reuse вместо destroy/recreate

  • Как: React reconciliation — только изменённые компоненты рендерятся
  • Для Hermes Hub: TeamView уже делает это. Применить к AccountsView и RoutingView.
  • Сложность: Средняя — рефакторинг AccountCardWidget по образцу AgentCardWidget

PATTERN 7: Typed EventBus

  • Как: Tauri emit(EVENT_NAME) / listen()
  • Для Hermes Hub: Расширить EventLogService до pub/sub — ~50 строк Python
  • Сложность: Низкая

PATTERN 8: Startup delay spreading

  • Как: STARTUP_AUTO_REFRESH_SETUP_DELAY_MS = 2500
  • Для Hermes Hub: self.after(50, ...)self.after(2500, ...)одна цифра!
  • Сложность: Тривиальная

15. Cockpit patterns NOT worth copying

НЕ КОПИРОВАТЬ: Per-provider Zustand stores (15 отдельных stores)

Cockpit — account manager для однородных провайдеров. Hermes Hub — Multi-Agent Control Hub с отношениями profile→role→routing_chain→session_affinity. Separate stores создадут silos, где routing decisions сложно агрегировать. Лучше для Hermes Hub: единый AgentStateStore с profiles: Dict[str, ProfileViewModel].

НЕ КОПИРОВАТЬ: Account-switcher архитектура

Cockpit: один активный аккаунт, остальные резерв. Hermes Hub работает с несколькими аккаунтами одновременно (orchestrator + coder1 + coder2 + reviewer). Принципиально другая модель.

НЕ КОПИРОВАТЬ: Instance management (process.rs, 325KB)

Cockpit: spawn IDE instances per account. Hermes Hub — control hub для routing, не IDE launcher.

НЕ КОПИРОВАТЬ: localStorage для credentials

Cockpit: AccountStore → localStorage (Zustand persist), credentials scrubbed. Hermes Hub хранит credentials в OS-level storage (Windows CM + files). Это правильно для Desktop.

НЕ КОПИРОВАТЬ: Backend перепись на Rust

Cockpit: 99% бизнес-логики в Rust. Python backend Hermes Hub содержит сложную логику (OAuth, subprocess, YAML routing). Перепись: 6-12 месяцев без UX-выгоды. Оставить Python backend, заменить только GUI.


16. Migration Plan: Tauri + React (Plan B)

CTk GUI продолжает работать на ВСЕХ фазах до Phase 7.

Phase 0 — API Contract (2 недели)

Расширить gui_server.py до полного coverage всех операций _handle_action(). Добавить WebSocket endpoint для push-событий. Написать OpenAPI spec. ROLLBACK: Нет изменений в production code.

Phase 1 — Tauri Shell (3 недели)

Minimal Tauri app + Python FastAPI sidecar, проверить round-trip. Files: tauri-app/ (Vite+React+TypeScript), tauri-app/src-tauri/ (Rust shell), hermesApi.ts. ROLLBACK: Удалить tauri-app/; CTk продолжает работать.

Phase 2 — Navigation Shell (2 недели)

React shell с sidebar, без бизнес-данных. Sidebar.tsx, Layout.tsx, useUIStore.ts. Risk: DPI scaling, Windows font rendering в WebView2.

Phase 3 — Accounts View (4 недели)

React AccountsView с реальными данными и delta updates. useProfileStore.ts, AccountCard.tsx, WebSocket listener для ACCOUNT_UPDATED.

Phase 4 — Team + Routing View (4 недели)

TeamView.tsx, AgentCard.tsx, read-only RoutingView.tsx.

Phase 5 — Routing Editor с D&D (6 недель)

Интерактивный routing editor. @dnd-kit/core dep, DraggableAgentSlot.tsx. Risk: Самая сложная phase. D&D state + Zustand. ROLLBACK: CTk RoutingView остаётся для power-users.

Phase 6 — Settings, Logs, Health, About (2 недели)

Оставшиеся views.

Phase 7 — Tray + Notifications (2 недели)

src-tauri/src/tray.rs, Tauri tray API.

Phase 8 — Remove Legacy CTk GUI (1 неделя)

Criteria: 2+ недели стабильной работы без критических багов. gui_server.py остаётся — он работает независимо.


17. Alternative Plan: CustomTkinter Optimization (Plan A)

STEP 1: Рефакторинг AccountsView — устранить destroy/recreate (ПРИОРИТЕТ 1, ~2 дня)

class AccountCardWidget(HubCard):
    """Delta-update account card — no destroy/recreate."""

    def __init__(self, master, on_action=None, **kwargs):
        super().__init__(...)
        # Создать ВСЕ sub-widgets ОДИН РАЗ
        self.identity_lbl = ctk.CTkLabel(self, ...)
        self.status_dot = ctk.CTkLabel(self, text="●", ...)
        self._profile_id: Optional[str] = None

    def update_profile(self, p: ProfileViewModel):
        """Delta update — только .configure(), никаких destroy."""
        self._profile_id = p.profile_id
        self.identity_lbl.configure(text=p.account_identity)
        dot_col = HEALTHY_COLOR if p.health_state == STATUS_HEALTHY else WARN_COLOR
        self.status_dot.configure(text_color=dot_col)
        # ... другие configure() вызовы

class AccountsView(ctk.CTkFrame):
    def __init__(self, ...):
        self._card_widgets: Dict[str, List[AccountCardWidget]] = {
            "antigravity": [], "openai-codex": [], "opencode-go": []
        }

    def update_data(self, profiles_by_prov=None):
        for prov_key, scroll in self.tab_scrolls.items():
            profiles = profiles_by_prov.get(prov_key, []) if profiles_by_prov else []
            widgets = self._card_widgets[prov_key]
            # Создать недостающие widgets
            while len(widgets) < len(profiles):
                idx = len(widgets)
                card = AccountCardWidget(scroll, on_action=self.on_action)
                row, col = divmod(idx, 3)
                card.grid(row=row, column=col, padx=6, pady=6, sticky="nsew")
                widgets.append(card)
            # Delta update существующих
            for idx, p in enumerate(profiles):
                widgets[idx].update_profile(p)
                widgets[idx].grid()
            # Скрыть лишние
            for idx in range(len(profiles), len(widgets)):
                widgets[idx].grid_remove()

IMPACT: Устраняет 750-1000 widget ops при 50 аккаунтах.

STEP 2: Устранить дублированные scan_all() (ПРИОРИТЕТ 1, ~1 день)

def _load():
    service = UnifiedHealthService.get()
    profiles_by_prov = service.scan_all()    # один раз!
    readiness = service.get_system_readiness()
    pipelines = service.get_routing_pipelines()
    agents = service.get_agent_view_models()
    self.after(0, lambda: self._on_data_loaded(readiness, profiles_by_prov, pipelines, agents))

def _on_data_loaded(self, readiness, profiles_by_prov, pipelines, agents):
    # Передаём готовые данные — никаких повторных scan_all!
    self._views["accounts"].update_data(profiles_by_prov=profiles_by_prov)
    self._views["team"].update_data(readiness=readiness, agents=agents)
    self._views["routing"].update_data(pipelines=pipelines)

STEP 3: Обновлять только активную вкладку (ПРИОРИТЕТ 2, ~0.5 дня)

def _on_data_loaded(self, readiness, ...):
    current = self._views.get(self._current_view)
    if current and hasattr(current, "update_data"):
        current.update_data(...)
    self._data_stale = False

def _show_view(self, view_name):
    ...
    target = self._views.get(view_name)
    if target and hasattr(target, "update_data") and self._data_stale:
        target.update_data(...)  # lazy update при переключении

STEP 4: Интегрировать Scheduler в GUI (ПРИОРИТЕТ 2, ~1 день)

# В HermesHubApp.__init__():
self._scheduler = ScheduledTaskSafetyCoordinator.get()
self._scheduler.register_task(ScheduledTaskSpec(
    task_id="gui_full_refresh",
    name="GUI auto-refresh",
    cron_or_interval_sec=300.0,  # 5 минут
    handler=self._background_refresh,
    overlap_policy="skip",
))
self._start_scheduler_ticker()

def _background_refresh(self):
    service = UnifiedHealthService.get()
    profiles_by_prov = service.scan_all(force=True)
    readiness = service.get_system_readiness()
    self.after(0, lambda: self._on_data_loaded(readiness, profiles_by_prov, ...))

STEP 5: Request Deduplication (ПРИОРИТЕТ 2, ~0.5 дня)

_pending_refresh: Optional[threading.Thread] = None
_refresh_dedup_lock = threading.Lock()

def _refresh_data(self):
    with self._refresh_dedup_lock:
        if self._pending_refresh and self._pending_refresh.is_alive():
            return  # уже выполняется
        t = threading.Thread(target=self._load, daemon=True)
        self._pending_refresh = t
    t.start()

STEP 6: Исправить _restore_status() (ПРИОРИТЕТ 1, ~1 час)

def _restore_status(self):
    # БЫЛО: синхронный вызов get_system_readiness() в UI thread
    # СТАЛО: dispatch в background
    def _fetch():
        readiness = UnifiedHealthService.get().get_system_readiness()
        self.after(0, lambda: self._update_status_bar(readiness))
    threading.Thread(target=_fetch, daemon=True).start()

STEP 7: EventBus + delta updates (ПРИОРИТЕТ 3, ~2 дня)

EventBus.subscribe("ACCOUNT_UPDATED", self._on_account_updated)

def _on_account_updated(self, data: dict):
    profile_id = data.get("profile_id")
    for widgets_list in self._card_widgets.values():
        for card in widgets_list:
            if card._profile_id == profile_id:
                card.update_profile_vm(data["profile_vm"])
                return

18. FINAL RECOMMENDATION

ВЫБОР: B — Tauri + React frontend, сохранив Python backend

Confidence: MEDIUM


Технические основания (VERIFIED из кода)

1. Drag-and-drop routing editor — принципиальный ceiling для CTk

routing_view.py — read-only (VERIFIED). D&D в CTk: ~500-1000 строк хрупкого кода. D&D в React (@dnd-kit): 20-30 строк production-ready. Если routing editor — ключевая фича, выбор очевиден.

2. FastAPI уже есть — IPC стоимость минимальна

gui_server.py — полноценный FastAPI backend (VERIFIED). Tauri frontend использует его через fetch() без переписывания бизнес-логики. Это нестандартная ситуация: обычно IPC — самая дорогая часть Tauri migration.

3. AccountsView destroy/recreate — structural constraint CTk

750-1000 widget ops при 50 аккаунтах per refresh. Исправляемо через widget reuse, НО виртуализации списков в CTk нет. При 200+ аккаунтах проблема вернётся на уровне фреймворка.

4. React ecosystem для Multi-Agent UI значительно богаче

Force graph (react-flow/D3), quota charts (recharts), routing canvas. CTk эквивалентов нет.

5. gui_cockpit.html (31KB) + gui_server.py доказывают:

Web-based GUI уже была идеей в проекте. Tauri — эволюция этого направления с native desktop интеграцией.

6. Python backend остаётся — нет риска потери бизнес-логики

agy_subprocess.py, runtime.py, OAuth flows, adapters — всё остаётся. Только GUI меняется. Python-only разработка backend продолжается.

7. Cockpit доказал: стек production-ready для этого класса задач

15+ провайдеров, сотни аккаунтов, scheduler, delta updates.


Почему confidence MEDIUM, а не HIGH

  • Migration 6-9 месяцев при параллельной разработке — высокий cost
  • Три технологии (Rust/TypeScript/Python) — выше bus factor
  • Если команда 1-2 человека — Plan A даёт 80% выгоды за 20% усилий
  • CTk Plan A, реализованный правильно, покрывает все требования на 12-18 месяцев

ЕСЛИ РЕСУРСЫ ОГРАНИЧЕНЫ: начать с Plan A, подготовить к Plan B

Первые шаги одинаковы для обоих путей:

Шаг Трудоёмкость IMPACT
Рефакторинг AccountsView (widget reuse) 2 дня Устраняет 1000 widget ops при 50 аккаунтах
Устранить дублированные scan_all() 1 день Минимум disk I/O per refresh
Исправить _restore_status() 1 час Устраняет UI freeze после каждого toast
Startup delay 50ms → 2500ms 5 минут Лучший UX при старте
Request deduplication 0.5 дня Нет дублирующих API calls
Подключить Scheduler к GUI 1 день Данные обновляются автоматически
Расширить REST API spec (gui_server.py) 2 дня Фундамент для Tauri migration
Исправить Win32 mutex handle 5 минут Single-instance guard работает
Singleton races (добавить Lock) 15 строк Thread-safe singletons
SessionAffinityTracker TTL eviction 20 строк Нет memory leak

После стабилизации Plan A → Scaffold Tauri app, проверить round-trip FastAPI → React, начать Phase 0.


Отчёт составлен на основе прямого анализа исходного кода обоих проектов. VERIFIED = подтверждено кодом с указанием файлов и строк. INFERENCE = логический вывод из видимых паттернов — явно помечен во всех местах.