diff --git a/docs/api.md b/docs/api.md index 5b2a002..1dafcde 100644 --- a/docs/api.md +++ b/docs/api.md @@ -599,6 +599,59 @@ --- +#### `GET /sessions/{session_id}/files` + +List all files and directories in the session's file directory (recursive, depth 10). + +**Response `200`** +```json +{ "session_id": "...", "files": [{"name": "...", "size": N, "is_dir": false, "path": "..."}, ...] } +``` + +**Errors** — `404` session not found; `403` no access. + +--- + +#### `GET /sessions/{session_id}/todos` + +Return the session's current todo list (the parent agent's plan). Sub-agent todos live in isolated KV rows and are not exposed here. Used by the TUI to seed the side panel on attach/switch. + +**Response `200`** +```json +{ "session_id": "...", "tasks": [{"index": 0, "text": "...", "status": "pending", "validation": "..."}, ...] } +``` + +**Errors** — `404` session not found; `403` no access. + +--- + +#### `GET /sessions/{session_id}/messages/archive` + +Return older archived messages for scroll-up (lazy history loading), paginated by `sequence_number`. + +**Query params** — `before_seq` (int, optional — load messages older than this sequence number), `limit` (int, default 50, 1–200). + +**Response `200`** +```json +{ "items": [, ...], "has_more": true, "next_before_seq": 123 } +``` + +**Errors** — `404` session not found; `403` no access. + +--- + +#### `POST /sessions/{session_id}/stop` + +Cooperatively stop an in-progress generation for the session. Sent via `fetch()` (not over the WebSocket) to avoid corrupting the WebSocket receive state. + +**Response `200`** +```json +{ "ok": true } // a run was active and signalled to stop +{ "ok": false, "reason": "no active run" } +``` + +--- + ### Messages (non-streaming) #### `POST /sessions/{session_id}/messages` @@ -719,9 +772,9 @@ | Field | Required | Description | |-----------|----------|-------------| -| `type` | yes | Always `"message"` | -| `content` | yes | Message text (non-empty) | -| `images` | no | Base64 image list. Both raw base64 and `data:image/...;base64,...` are accepted — server strips the prefix | +| `type` | yes | `"message"` (a user turn) or `"compact"` (force context compression now — server streams `compression_started` → `context_compressed`, no `stream_start`; rejected if a run is active) | +| `content` | yes | Message text (non-empty), required for `"message"` | +| `images` | no | Base64 image list. Max 8 images, 50 MB total payload. Both raw base64 and `data:image/...;base64,...` are accepted — server strips the prefix | | `files` | no | Files uploaded via `POST /sessions/{id}/files`. Server appends their paths to the message content | --- @@ -877,7 +930,7 @@ "summary": "User asked about..." } ``` -Context was automatically compressed (triggers at ≥80% of context window). Informational. +Context was automatically compressed (triggers at ≥70% of `OLLAMA_NUM_CTX`, or on demand via `{"type":"compact"}`). `summary` is the produced summary text. Informational. --- @@ -901,7 +954,7 @@ "action": "scheduled" } ``` -Recall state changed. Sent when a recall is scheduled, cancelled, fired, or rescheduled. Client should refresh the recall banner via `GET /sessions/{id}/recall`. +Recall state changed. Sent when a recall is scheduled, cancelled, skipped, fired, or rescheduled. Client should refresh the recall banner via `GET /sessions/{id}/recall`. **Fields** | Field | Type | Description | @@ -911,15 +964,39 @@ | `call_type` | `string\|null` | `once`, `recurring`, `immediate` | | `trigger_at` | `string\|null` | Next trigger time (ISO 8601) | | `status` | `string\|null` | `pending`, `fired`, `cancelled` | -| `action` | `string\|null` | `scheduled`, `cancelled`, `fired`, `rescheduled` | +| `action` | `string\|null` | `scheduled`, `cancelled`, `skipped`, `fired`, `rescheduled` | + +--- + +#### `model_info` +```json +{ "type": "model_info", "model": "gemma4:31b-cloud" } +``` +Emitted once per turn after the backend resolves a model (may differ from the profile's configured model when fallback picked another). Additive. + +--- + +#### `todo_updated` +```json +{ "type": "todo_updated", "session_id": "...", "tasks": [{"index": 0, "text": "...", "status": "in_progress", "validation": "..."}] } +``` +Session todo list changed (auto-populated from the plan, or via the `todo` tool). Sub-agent todos are NOT emitted here. Additive. + +--- + +#### `compression_started` +```json +{ "type": "compression_started", "context_tokens": 46000, "max_context_tokens": 65536 } +``` +Emitted immediately before context compression begins — client can show a spinner. --- #### `session_sync` ```json -{ "type": "session_sync" } +{ "type": "session_sync", "session_id": "...", "profile_id": "..." } ``` -Client must reload session history from `GET /sessions/{id}`. Sent: +Client must reload session history from `GET /sessions/{id}`. Carries the active `session_id` and `profile_id`. Sent: 1. On connect when no run is active (agent may have finished while disconnected). 2. After a reconnect-replay flow completes (ensures client sees the fully saved response). 3. After a headless recall run finishes (so the client sees the recall user message + assistant response). @@ -1331,6 +1408,31 @@ --- +#### Admin MCP management + +All require admin role. + +| Endpoint | Purpose | +|---|---| +| `GET /admin/mcp/config` | Bulk read all MCP server configs (`mcp_servers.d/`). | +| `PUT /admin/mcp/config` | Bulk write all MCP server configs. | +| `GET /admin/mcp/config/{server_name}` | Read one server config. | +| `PUT /admin/mcp/config/{server_name}` | Create/update one server config. | +| `DELETE /admin/mcp/config/{server_name}` | Delete one server config. | +| `POST /admin/mcp/{server_name}/reconnect` | Drop old client, unregister tools, connect fresh, re-register. | +| `GET /admin/mcp/status` | List servers with connection status and exposed tools. | +| `POST /admin/mcp/test` | Execute a single MCP tool call in isolation for diagnostics. | + +#### `GET /admin/profiles/{profile_id}/mcp` · `PUT /admin/profiles/{profile_id}/mcp` + +Read/write a profile's `tools.agent.mcp` / `tools.subagent.mcp` mapping (which MCP groups the profile exposes). Requires `navi.profiles.manage`. + +#### `GET /admin/recalls` + +List all scheduled recalls with pagination/filtering (admin view across all users/sessions). + +--- + ### Webhooks #### `POST /webhooks/gnexus-auth` diff --git a/docs/architecture_weak_spots.md b/docs/architecture_weak_spots.md deleted file mode 100644 index 4ffd455..0000000 --- a/docs/architecture_weak_spots.md +++ /dev/null @@ -1,184 +0,0 @@ -# Архитектурные слабые места Navi - -Список проблем, выявленных в ходе аудита 2026-05-16. -Решаем по одной, сверху вниз. После исправления каждого пункта — отмечать галочкой и обновлять этот файл. - ---- - -## 1. God object `navi/core/agent.py` ✅ - -**Severity:** Critical -**Файл:** `navi/core/agent.py` (1349 → ~410 строк) -**Проблема:** Класс `Agent` одновременно управляет тремя режимами (`run`, `run_stream`, `run_ephemeral`), компрессией контекста (3 уровня fallback), планированием, анти-столлингом, адаптивным репланингом, подсчётом токенов, stall-детекцией, обработкой изображений и под-агентами. -**Почему блокер:** Любое изменение в одной подсистеме требует правки одного файла. Параллельная работа нескольких разработчиков невозможна без конфликтов. Unit-тесты вынуждены инициализировать весь агент даже для проверки одного метода. -**Направление:** Выделить `PlanningOrchestrator`, `ContextCompressor`, `SubAgentRunner`, `AntiStallMonitor` в отдельные сервисы. `Agent` должен остаться только координатором. - -**Решение 2026-05-16:** -- `ContextCompressor` → `navi/core/compressor.py` -- `AntiStallMonitor` → `navi/core/anti_stall.py` -- `SubAgentRunner` → `navi/core/subagent_runner.py` -- `AgentTurnContext` / `StreamState` → `navi/core/agent_run_context.py` -- `_iter_stream_guarded` → `navi/core/stream_guard.py` -- `build_tool_list` / `load_user_enabled_tools` → `navi/core/tool_utils.py` -- `run()` — тонкая обёртка вокруг `run_stream()` -- `run_stream()` делегирует `_compression_events_preturn`, `_compression_events_midturn`, `_consume_stream`, `_execute_tools_with_sink` -- `run_ephemeral()` делегирует `SubAgentRunner.run()` - ---- - -## 2. Глобальные ленивые синглтоны в `navi/api/deps.py` - -**Severity:** Critical -**Файл:** `navi/api/deps.py` (строки 46–170) -**Проблема:** `_memory_store`, `_registries`, `_mcp_manager`, `_scheduler`, `_kv_store`, `_session_store`, `_workers` — создаются при первом обращении и живут до перезапуска процесса. Нет явного lifecycle management (shutdown pools, close connections). -**Почему блокер:** Невозможно подменить реализацию в тестах без `monkeypatch` на уровне модуля. Горизонтальное масштабирование (несколько процессов) невозможно, потому что состояние привязано к процессу. -**Направление:** Заменить на явный `AppContainer` или `asynccontextmanager`-зависимости FastAPI с `yield`. - ---- - -## 3. WebSocket handler содержит бизнес-логику - -**Severity:** High -**Файл:** `navi/api/websocket.py` (443 строки) -**Проблема:** WebSocket handler занимается: heartbeat, replay/reconnect, оркестрацией запуска агента, аутентификацией, валидацией изображений, управлением глобальным состоянием сессий (`_runs`, `_busy_sessions`, `_session_sockets`), созданием `Agent` напрямую. -**Почему блокер:** При добавлении нового транспорта (SSE, gRPC) придётся дублировать всю оркестрацию. Бизнес-логика просочилась в слой сериализации. -**Направление:** Ввести `AgentSessionOrchestrator` между WebSocket и `Agent`. WebSocket должен заниматься только сериализацией/десериализацией. - ---- - -## 4. Mutable global `settings` - -**Severity:** High -**Файл:** `navi/config.py` -**Проблема:** `settings = Settings()` доступен из любого модуля. Поля не readonly. `extra="ignore"` означает, что опечатки в `.env` игнорируются без предупреждения. -**Почему блокер:** Любой модуль может изменить `settings.ollama_num_ctx` во время выполнения, что приведёт к race condition при параллельных запросах. -**Направление:** Сделать `Settings` immutable (`frozen=True`). Передавать экземпляр в конструкторы вместо глобального импорта. - -**Решение 2026-05-18:** -- `frozen=True` добавлено в `SettingsConfigDict` в `navi/config.py` -- `model_validator(mode="after")` конвертирован в `mode="before"` для загрузки `navi_persona_file`, т.к. frozen-инстанс нельзя мутировать после создания -- Все тесты, которые мутировали поля `settings.field = value`, переведены на замену целого объекта: `monkeypatch.setattr(module, "settings", Settings(...))` -- Для модулей, вызывающих `session_dir` / `ensure_session_dir` из `navi.session_files`, настройки заменяются согласованно в обоих модулях - ---- - -## 5. Дублирование пулов PostgreSQL - -**Severity:** High -**Файлы:** `navi/memory/store.py`, `navi/store/__init__.py`, `navi/core/pg_session_store.py` -**Проблема:** Каждый стор создаёт свой `asyncpg.create_pool(self._dsn)` с одинаковым DSN. У каждого своя `asyncio.Lock` для lazy-инициализации. -**Почему блокер:** При росте нагрузки количество соединений к PostgreSQL утраивается. Нет единого менеджера пула. -**Направление:** Вынести `asyncpg.Pool` в отдельный `Database` сервис. Передавать `pool` конструктором в сторы. - -**Решение 2026-05-18:** -- Создан `navi/db.py::Database` — единый менеджер пула с lazy-инициализацией -- `KvStore`, `PgSessionStore`, `MemoryStore`, `RecallScheduler` теперь принимают `pool` в `__init__` вместо `dsn` -- `AppContainer` хранит `database: Database`, `shutdown()` закрывает один пул -- `create_container()` создаёт один пул и передаёт его всем сторам -- Lazy-DDL в каждом сторе (через `_initialized` + `_lock`) сохранён для изоляции схем - ---- - -## 6. Кросс-реестровый патчинг в `registry.py` - -**Severity:** High -**Файл:** `navi/core/registry.py` (строки 164–254) -**Проблема:** `build_default_registries()` создаёт все реестры, а затем вручную прописывает кросс-ссылки (`list_tool._profile_registry = profiles`, `spawn_tool._backend_registry = backends`). -**Почему блокер:** Циклические зависимости разрешаются через "патч после создания". Добавление нового инструмента требует редактирования фабрики. -**Направление:** Внедрить двухфазную инициализацию: `create()` → `wire()`. - -**Решение 2026-05-18:** -- Переупорядочена `build_default_registries`: сначала создаются `backends`, `profiles`, `cp_registry` (нет зависимостей на tools), затем создаются ВСЕ инструменты с полными зависимостями -- Все инструменты получают полные зависимости в конструкторе, без `None` и последующего патчинга: - `ListToolsTool(registry=tools, profile_registry=profiles, mcp_manager=mcp_manager)` - `SpawnAgentTool(backend_registry=backends, ...)` - `ReloadToolsTool(registry=tools, cp_registry=cp_registry, mcp_manager=mcp_manager)` -- Удалены строки патчинга `_profile_registry = profiles`, `_backend_registry = backends`, `_cp_registry = cp_registry` -- Добавлен параметр `mcp_manager` в `build_default_registries` (раньше передавался через патч в `create_container()`) -- Новый инструмент с кросс-зависимостью добавляется в список `builtins` — никакого патчинга не требуется - ---- - -## 7. DRY-нарушение в `tool_executor.py` - -**Severity:** Medium -**Файл:** `navi/core/tool_executor.py` (строки 60–187) -**Проблема:** Три метода (`_run_single_tool`, `_execute_tool_calls`, `_execute_tool_calls_streaming`) содержат идентичную логику: resolve → middleware → execute → image extraction → build message. -**Почему блокер:** Любой баг в middleware или image-обработке нужно править в трёх местах. -**Направление:** Единый метод `_execute_one(tc, tool_map) -> (event, msg, image_msg)`, используемый всеми тремя путями. - -**Решение 2026-05-18:** -- Введён единый `_execute_one(tc, tool_map)` — единственный канонический путь resolve → middleware → execute → image extraction → build message -- `_run_single_tool`, `_execute_tool_calls`, `_execute_tool_calls_streaming` делегируют `_execute_one` -- Публичные сигнатуры не изменились — никакие вызывающие стороны не пострадали -- Удалено ~77 строк дублирования, добавлено ~21 строк единой логики - ---- - -## 8. Скрытые глобальные зависимости через ContextVar ✅ - -**Severity:** Medium -**Файл:** `navi/tools/_internal/base.py` (строки 19–42) -**Проблема:** 7 глобальных ContextVar (`current_session_id`, `current_event_sink`, `current_stop_event`, `current_model`, `current_user_id`, `current_user_role`, `current_user_info`). Инструменты читают их неявно. -**Почему блокер:** Инструмент нельзя вызвать вне контекста агента (из CLI, фоновой задачи, теста) без установки всех ContextVar. -**Направление:** Передавать контекст выполнения явным параметром в `execute()`. ContextVar оставить как optional fallback. - -**Решение 2026-05-24:** -- Создан `ToolContext` dataclass в `navi/tools/_internal/base.py` — явный контейнер для всех 7 значений -- `Tool.execute()` теперь принимает `ctx: ToolContext | None = None` -- `ToolExecutor._execute_one()` собирает `ToolContext` и передаёт его инструменту -- `Agent._execute_tools_with_sink()` и `SubAgentRunner` строят `ToolContext` из значений в scope и передают в цепочку -- Все ~25 инструментов обновлены: читающие ContextVar теперь предпочитают `ctx`, остальные получили только новый параметр -- Все тесты инструментов переведены на явный `ctx=ToolContext(...)` — больше никаких фикстур с `current_session_id.set()` -- ContextVar setters оставлены как fallback для не-инструментных потребителей (`ai_helper.py`, `context_builder.py`, `planning.py`) - ---- - -## 9. Сессионное состояние в памяти процесса ✅ - -**Severity:** Medium -**Файл:** `navi/api/websocket.py` (строки 80–86, 403–406) -**Проблема:** `_runs`, `_busy_sessions`, `_session_sockets` — глобальные mutable dict без явной синхронизации. При горизонтальном масштабировании (несколько процессов) состояние запуска не реплицируется. -**Почему блокер:** Сессия может быть запущена на инстансе A, а WebSocket подключён к инстансу B. -**Направление:** Вынести состояние запуска в `SessionStore` (PostgreSQL) или Redis. `_session_sockets` заменить на pub/sub. - -**Решение 2026-05-24:** -- Создан `SessionState` dataclass в `navi/core/orchestrator.py` — единый контейнер для `run`, `busy_event`, `websockets` -- `_session_sockets` module-level global удалён из `websocket.py` и перенесён в `AgentSessionOrchestrator._sessions` -- Event bus subscriber `_on_recall_update` перенесён из `websocket.py` в `AgentSessionOrchestrator` -- Добавлен `asyncio.Lock` per session_id — `AgentSessionOrchestrator.session_lock()` защищает concurrent-run guard от race condition -- WebSocket handler теперь использует `orchestrator.add_websocket()` / `remove_websocket()` и `async with orchestrator.session_lock()` -- `_cleanup()` удаляет пустые `SessionState` entries автоматически -- Для горизонтального масштабирования всё ещё требуется Redis/pub-sub (не в scope этого фикса), но in-memory state теперь unified и explicit - ---- - -## 10. MCP: чтение конфига с диска на каждый вызов + retry без backoff - -**Severity:** Medium -**Файлы:** `navi/mcp/manager.py`, `navi/mcp/client.py` -**Проблема:** `resolve_group()` и `get_instructions()` вызывают `load_mcp_servers()` при каждом обращении. Нет кэширования. `McpClient` делает reconnect без backoff. -**Почему блокер:** При частых запросах — лишний дисковый I/O. Если MCP-сервер упал, каждый запрос порождает две попытки подключения без задержки. -**Направление:** Закэшировать конфигурацию в `McpManager`. Добавить exponential backoff на reconnect в `McpClient`. - -**Решение 2026-05-18:** -- `McpManager` теперь хранит `_configs: dict[str, McpServerConfig]`, заполняемый при `load_all()` -- `resolve_group()` и `get_instructions()` читают из кэша; fallback к диску если кэш пуст (тесты / первый вызов без `load_all()`) -- `reload_all()` сбрасывает кэш (`self._configs = None`) и перечитывает конфиг -- `McpClient` получил exponential backoff: base 1s, max 30s, ±20% jitter -- `_ensure_connected()` блокирует reconnect в пределах backoff-окна; backoff сбрасывается при успешном подключении, удваивается при неудаче - ---- - -## Прогресс - -- [x] 1. God object `agent.py` — Step 1 complete: `ContextCompressor` extracted -- [x] 2. Глобальные синглтоны `deps.py` — replaced with AppContainer + lifespan -- [x] 3. WebSocket handler содержит бизнес-логику — extracted AgentSessionOrchestrator -- [x] 4. Mutable global `settings` — frozen Settings + mode="before" validator -- [x] 5. Дублирование пулов PostgreSQL — unified Database service, pool passed to constructors -- [x] 6. Кросс-реестровый патчинг — proper creation order, no post-hoc patching -- [x] 7. DRY-нарушение `tool_executor.py` — unified _execute_one, three methods delegate -- [ ] 8. ContextVar как скрытые зависимости -- [ ] 9. Сессионное состояние в памяти -- [x] 10. MCP кэширование и backoff — cached configs + exponential reconnect backoff diff --git a/docs/archive/architecture_weak_spots.md b/docs/archive/architecture_weak_spots.md new file mode 100644 index 0000000..4ffd455 --- /dev/null +++ b/docs/archive/architecture_weak_spots.md @@ -0,0 +1,184 @@ +# Архитектурные слабые места Navi + +Список проблем, выявленных в ходе аудита 2026-05-16. +Решаем по одной, сверху вниз. После исправления каждого пункта — отмечать галочкой и обновлять этот файл. + +--- + +## 1. God object `navi/core/agent.py` ✅ + +**Severity:** Critical +**Файл:** `navi/core/agent.py` (1349 → ~410 строк) +**Проблема:** Класс `Agent` одновременно управляет тремя режимами (`run`, `run_stream`, `run_ephemeral`), компрессией контекста (3 уровня fallback), планированием, анти-столлингом, адаптивным репланингом, подсчётом токенов, stall-детекцией, обработкой изображений и под-агентами. +**Почему блокер:** Любое изменение в одной подсистеме требует правки одного файла. Параллельная работа нескольких разработчиков невозможна без конфликтов. Unit-тесты вынуждены инициализировать весь агент даже для проверки одного метода. +**Направление:** Выделить `PlanningOrchestrator`, `ContextCompressor`, `SubAgentRunner`, `AntiStallMonitor` в отдельные сервисы. `Agent` должен остаться только координатором. + +**Решение 2026-05-16:** +- `ContextCompressor` → `navi/core/compressor.py` +- `AntiStallMonitor` → `navi/core/anti_stall.py` +- `SubAgentRunner` → `navi/core/subagent_runner.py` +- `AgentTurnContext` / `StreamState` → `navi/core/agent_run_context.py` +- `_iter_stream_guarded` → `navi/core/stream_guard.py` +- `build_tool_list` / `load_user_enabled_tools` → `navi/core/tool_utils.py` +- `run()` — тонкая обёртка вокруг `run_stream()` +- `run_stream()` делегирует `_compression_events_preturn`, `_compression_events_midturn`, `_consume_stream`, `_execute_tools_with_sink` +- `run_ephemeral()` делегирует `SubAgentRunner.run()` + +--- + +## 2. Глобальные ленивые синглтоны в `navi/api/deps.py` + +**Severity:** Critical +**Файл:** `navi/api/deps.py` (строки 46–170) +**Проблема:** `_memory_store`, `_registries`, `_mcp_manager`, `_scheduler`, `_kv_store`, `_session_store`, `_workers` — создаются при первом обращении и живут до перезапуска процесса. Нет явного lifecycle management (shutdown pools, close connections). +**Почему блокер:** Невозможно подменить реализацию в тестах без `monkeypatch` на уровне модуля. Горизонтальное масштабирование (несколько процессов) невозможно, потому что состояние привязано к процессу. +**Направление:** Заменить на явный `AppContainer` или `asynccontextmanager`-зависимости FastAPI с `yield`. + +--- + +## 3. WebSocket handler содержит бизнес-логику + +**Severity:** High +**Файл:** `navi/api/websocket.py` (443 строки) +**Проблема:** WebSocket handler занимается: heartbeat, replay/reconnect, оркестрацией запуска агента, аутентификацией, валидацией изображений, управлением глобальным состоянием сессий (`_runs`, `_busy_sessions`, `_session_sockets`), созданием `Agent` напрямую. +**Почему блокер:** При добавлении нового транспорта (SSE, gRPC) придётся дублировать всю оркестрацию. Бизнес-логика просочилась в слой сериализации. +**Направление:** Ввести `AgentSessionOrchestrator` между WebSocket и `Agent`. WebSocket должен заниматься только сериализацией/десериализацией. + +--- + +## 4. Mutable global `settings` + +**Severity:** High +**Файл:** `navi/config.py` +**Проблема:** `settings = Settings()` доступен из любого модуля. Поля не readonly. `extra="ignore"` означает, что опечатки в `.env` игнорируются без предупреждения. +**Почему блокер:** Любой модуль может изменить `settings.ollama_num_ctx` во время выполнения, что приведёт к race condition при параллельных запросах. +**Направление:** Сделать `Settings` immutable (`frozen=True`). Передавать экземпляр в конструкторы вместо глобального импорта. + +**Решение 2026-05-18:** +- `frozen=True` добавлено в `SettingsConfigDict` в `navi/config.py` +- `model_validator(mode="after")` конвертирован в `mode="before"` для загрузки `navi_persona_file`, т.к. frozen-инстанс нельзя мутировать после создания +- Все тесты, которые мутировали поля `settings.field = value`, переведены на замену целого объекта: `monkeypatch.setattr(module, "settings", Settings(...))` +- Для модулей, вызывающих `session_dir` / `ensure_session_dir` из `navi.session_files`, настройки заменяются согласованно в обоих модулях + +--- + +## 5. Дублирование пулов PostgreSQL + +**Severity:** High +**Файлы:** `navi/memory/store.py`, `navi/store/__init__.py`, `navi/core/pg_session_store.py` +**Проблема:** Каждый стор создаёт свой `asyncpg.create_pool(self._dsn)` с одинаковым DSN. У каждого своя `asyncio.Lock` для lazy-инициализации. +**Почему блокер:** При росте нагрузки количество соединений к PostgreSQL утраивается. Нет единого менеджера пула. +**Направление:** Вынести `asyncpg.Pool` в отдельный `Database` сервис. Передавать `pool` конструктором в сторы. + +**Решение 2026-05-18:** +- Создан `navi/db.py::Database` — единый менеджер пула с lazy-инициализацией +- `KvStore`, `PgSessionStore`, `MemoryStore`, `RecallScheduler` теперь принимают `pool` в `__init__` вместо `dsn` +- `AppContainer` хранит `database: Database`, `shutdown()` закрывает один пул +- `create_container()` создаёт один пул и передаёт его всем сторам +- Lazy-DDL в каждом сторе (через `_initialized` + `_lock`) сохранён для изоляции схем + +--- + +## 6. Кросс-реестровый патчинг в `registry.py` + +**Severity:** High +**Файл:** `navi/core/registry.py` (строки 164–254) +**Проблема:** `build_default_registries()` создаёт все реестры, а затем вручную прописывает кросс-ссылки (`list_tool._profile_registry = profiles`, `spawn_tool._backend_registry = backends`). +**Почему блокер:** Циклические зависимости разрешаются через "патч после создания". Добавление нового инструмента требует редактирования фабрики. +**Направление:** Внедрить двухфазную инициализацию: `create()` → `wire()`. + +**Решение 2026-05-18:** +- Переупорядочена `build_default_registries`: сначала создаются `backends`, `profiles`, `cp_registry` (нет зависимостей на tools), затем создаются ВСЕ инструменты с полными зависимостями +- Все инструменты получают полные зависимости в конструкторе, без `None` и последующего патчинга: + `ListToolsTool(registry=tools, profile_registry=profiles, mcp_manager=mcp_manager)` + `SpawnAgentTool(backend_registry=backends, ...)` + `ReloadToolsTool(registry=tools, cp_registry=cp_registry, mcp_manager=mcp_manager)` +- Удалены строки патчинга `_profile_registry = profiles`, `_backend_registry = backends`, `_cp_registry = cp_registry` +- Добавлен параметр `mcp_manager` в `build_default_registries` (раньше передавался через патч в `create_container()`) +- Новый инструмент с кросс-зависимостью добавляется в список `builtins` — никакого патчинга не требуется + +--- + +## 7. DRY-нарушение в `tool_executor.py` + +**Severity:** Medium +**Файл:** `navi/core/tool_executor.py` (строки 60–187) +**Проблема:** Три метода (`_run_single_tool`, `_execute_tool_calls`, `_execute_tool_calls_streaming`) содержат идентичную логику: resolve → middleware → execute → image extraction → build message. +**Почему блокер:** Любой баг в middleware или image-обработке нужно править в трёх местах. +**Направление:** Единый метод `_execute_one(tc, tool_map) -> (event, msg, image_msg)`, используемый всеми тремя путями. + +**Решение 2026-05-18:** +- Введён единый `_execute_one(tc, tool_map)` — единственный канонический путь resolve → middleware → execute → image extraction → build message +- `_run_single_tool`, `_execute_tool_calls`, `_execute_tool_calls_streaming` делегируют `_execute_one` +- Публичные сигнатуры не изменились — никакие вызывающие стороны не пострадали +- Удалено ~77 строк дублирования, добавлено ~21 строк единой логики + +--- + +## 8. Скрытые глобальные зависимости через ContextVar ✅ + +**Severity:** Medium +**Файл:** `navi/tools/_internal/base.py` (строки 19–42) +**Проблема:** 7 глобальных ContextVar (`current_session_id`, `current_event_sink`, `current_stop_event`, `current_model`, `current_user_id`, `current_user_role`, `current_user_info`). Инструменты читают их неявно. +**Почему блокер:** Инструмент нельзя вызвать вне контекста агента (из CLI, фоновой задачи, теста) без установки всех ContextVar. +**Направление:** Передавать контекст выполнения явным параметром в `execute()`. ContextVar оставить как optional fallback. + +**Решение 2026-05-24:** +- Создан `ToolContext` dataclass в `navi/tools/_internal/base.py` — явный контейнер для всех 7 значений +- `Tool.execute()` теперь принимает `ctx: ToolContext | None = None` +- `ToolExecutor._execute_one()` собирает `ToolContext` и передаёт его инструменту +- `Agent._execute_tools_with_sink()` и `SubAgentRunner` строят `ToolContext` из значений в scope и передают в цепочку +- Все ~25 инструментов обновлены: читающие ContextVar теперь предпочитают `ctx`, остальные получили только новый параметр +- Все тесты инструментов переведены на явный `ctx=ToolContext(...)` — больше никаких фикстур с `current_session_id.set()` +- ContextVar setters оставлены как fallback для не-инструментных потребителей (`ai_helper.py`, `context_builder.py`, `planning.py`) + +--- + +## 9. Сессионное состояние в памяти процесса ✅ + +**Severity:** Medium +**Файл:** `navi/api/websocket.py` (строки 80–86, 403–406) +**Проблема:** `_runs`, `_busy_sessions`, `_session_sockets` — глобальные mutable dict без явной синхронизации. При горизонтальном масштабировании (несколько процессов) состояние запуска не реплицируется. +**Почему блокер:** Сессия может быть запущена на инстансе A, а WebSocket подключён к инстансу B. +**Направление:** Вынести состояние запуска в `SessionStore` (PostgreSQL) или Redis. `_session_sockets` заменить на pub/sub. + +**Решение 2026-05-24:** +- Создан `SessionState` dataclass в `navi/core/orchestrator.py` — единый контейнер для `run`, `busy_event`, `websockets` +- `_session_sockets` module-level global удалён из `websocket.py` и перенесён в `AgentSessionOrchestrator._sessions` +- Event bus subscriber `_on_recall_update` перенесён из `websocket.py` в `AgentSessionOrchestrator` +- Добавлен `asyncio.Lock` per session_id — `AgentSessionOrchestrator.session_lock()` защищает concurrent-run guard от race condition +- WebSocket handler теперь использует `orchestrator.add_websocket()` / `remove_websocket()` и `async with orchestrator.session_lock()` +- `_cleanup()` удаляет пустые `SessionState` entries автоматически +- Для горизонтального масштабирования всё ещё требуется Redis/pub-sub (не в scope этого фикса), но in-memory state теперь unified и explicit + +--- + +## 10. MCP: чтение конфига с диска на каждый вызов + retry без backoff + +**Severity:** Medium +**Файлы:** `navi/mcp/manager.py`, `navi/mcp/client.py` +**Проблема:** `resolve_group()` и `get_instructions()` вызывают `load_mcp_servers()` при каждом обращении. Нет кэширования. `McpClient` делает reconnect без backoff. +**Почему блокер:** При частых запросах — лишний дисковый I/O. Если MCP-сервер упал, каждый запрос порождает две попытки подключения без задержки. +**Направление:** Закэшировать конфигурацию в `McpManager`. Добавить exponential backoff на reconnect в `McpClient`. + +**Решение 2026-05-18:** +- `McpManager` теперь хранит `_configs: dict[str, McpServerConfig]`, заполняемый при `load_all()` +- `resolve_group()` и `get_instructions()` читают из кэша; fallback к диску если кэш пуст (тесты / первый вызов без `load_all()`) +- `reload_all()` сбрасывает кэш (`self._configs = None`) и перечитывает конфиг +- `McpClient` получил exponential backoff: base 1s, max 30s, ±20% jitter +- `_ensure_connected()` блокирует reconnect в пределах backoff-окна; backoff сбрасывается при успешном подключении, удваивается при неудаче + +--- + +## Прогресс + +- [x] 1. God object `agent.py` — Step 1 complete: `ContextCompressor` extracted +- [x] 2. Глобальные синглтоны `deps.py` — replaced with AppContainer + lifespan +- [x] 3. WebSocket handler содержит бизнес-логику — extracted AgentSessionOrchestrator +- [x] 4. Mutable global `settings` — frozen Settings + mode="before" validator +- [x] 5. Дублирование пулов PostgreSQL — unified Database service, pool passed to constructors +- [x] 6. Кросс-реестровый патчинг — proper creation order, no post-hoc patching +- [x] 7. DRY-нарушение `tool_executor.py` — unified _execute_one, three methods delegate +- [ ] 8. ContextVar как скрытые зависимости +- [ ] 9. Сессионное состояние в памяти +- [x] 10. MCP кэширование и backoff — cached configs + exponential reconnect backoff diff --git a/docs/archive/future_headless_nodes.md b/docs/archive/future_headless_nodes.md new file mode 100644 index 0000000..63fa782 --- /dev/null +++ b/docs/archive/future_headless_nodes.md @@ -0,0 +1,134 @@ +# Headless Navi Nodes — Future Architecture Sketch + +**Status:** Research / deferred. Not in active development. +**Date:** 2026-05-24 + +--- + +## Problem + +Navi currently runs all tools (`terminal`, `filesystem`, `code_exec`, `ssh_exec`) on the same machine as the backend server. Users who want Navi to manage their local dev machine must either: + +1. Run the entire Navi backend locally (heavy, requires PostgreSQL). +2. Use `ssh_exec` to loopback to `localhost` (clunky, requires local sshd). + +The original idea — a "terminal client" that lets the browser execute commands on the user's local machine — was explored and rejected. + +--- + +## Rejected Approach: Browser-Based Client-Side Execution + +### Why it was considered +A browser-based client could theoretically receive a `terminal` command from the server, run it locally via a companion app or extension, and return stdout. + +### Why it was rejected +1. **Sandbox impossibility.** Browsers cannot spawn local shells. A companion (Electron / Tauri / browser extension + native messaging host) is required, which is no longer a "web client". +2. **Agent loop blocking.** `Agent.run_stream` assumes tools are synchronous `await tool.execute()` calls inside a single Python process. A remote tool that waits for a browser response would freeze the entire agent loop or require a full async-state-machine refactor. +3. **C2 / trust model.** The server instructing the client to execute arbitrary commands is a command-and-control pattern. Authentication, authorization, and sandboxing of the client-side executor become critical and complex. +4. **Device ambiguity.** If a user has Navi open on desktop and mobile, which device executes `terminal("npm install")`? Requires device registry, affinity, and explicit routing. +5. **Maintenance burden.** Supporting three platforms (Linux, macOS, Windows) with installable companion software is unsustainable for a personal-assistant project. + +**Conclusion:** Browser-based client-side execution is architecturally incompatible with Navi's current synchronous tool loop and operationally too expensive. + +--- + +## Preferred Approach: Headless Navi Nodes (Swarm) + +### Concept +A **headless Navi node** is a lightweight instance of the Navi backend (FastAPI + agent loop) without a web client. It runs on the target machine (e.g. a user's dev laptop, a VPS, a home server) and connects back to a **central Navi server**. + +- The **central server** handles user-facing sessions, web client, and orchestration. +- **Headless nodes** handle local tool execution on their respective hosts. +- Both share a **common PostgreSQL database** for session persistence and scheduler state. +- Communication is via **outbound WebSocket** from node to central server (avoids NAT issues). + +### High-level diagram + +``` +┌──────────────┐ WS/HTTP ┌──────────────┐ +│ Browser │◄───────────────────►│ Central │ +│ (web client)│ │ Navi Server │ +└──────────────┘ └──────┬───────┘ + │ + │ WS outbound + │ (nodes register here) + │ + ┌───────────────────────────────────┼───────────────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ + │ Headless │ │ Headless │ │ Headless │ + │ Node A │ │ Node B │ │ Node C │ + │ (dev laptop)│ │ (home NAS) │ │ (VPS) │ + └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ + │ │ │ + ▼ local shell ▼ local shell ▼ local shell +``` + +### Advantages +- **No agent loop changes.** `terminal`, `filesystem`, `code_exec` remain synchronous Python tools inside the node's process. +- **No browser sandbox issues.** Tools run in a real OS process on the target machine. +- **NAT-friendly.** Nodes initiate outbound connections; no reverse tunnels or port forwarding needed. +- **Composable.** A user can attach as many machines as needed. Central server routes tasks to the appropriate node. + +--- + +## Open Questions (To Be Solved Before Implementation) + +### 1. Shared Database Partitioning +If all nodes share one PostgreSQL database: +- **Scheduler race.** Multiple nodes polling `recalls` will try to execute the same scheduled task. Needs `claimed_by` column or a leader-election mechanism per recall. +- **Session concurrent edits.** Two nodes appending to the same session's `messages` array could overwrite each other. Needs row-level locking or `instance_id` partitioning. +- **Memory extractor storm.** `process_stale_sessions` on every node would duplicate embedding work. Needs `instance_id` gating so only the central server or a designated node runs background workers. + +**Direction:** Tag every row with `instance_id` (central = `main`). Nodes only read/write rows assigned to them. The scheduler table gets a `claimed_by` atomic UPDATE. + +### 2. Tool Routing in the Agent +The agent on the central server must know that `terminal` for profile `server_admin` should run on Node B, not locally. + +Options: +- **Profile-level node affinity.** Profile `server_admin` has `node_id: "home-nas"`. All tools in that profile execute on that node. +- **Remote tool proxy.** The central registry has proxy tools (`remote_terminal`, `remote_filesystem`) that forward calls to the node's REST/WebSocket API. +- **Subagent on node.** `spawn_agent` spawns the subagent on a remote node via the node's API instead of locally. + +### 3. Headless Node Packaging +- **Docker:** Easy to ship, but `terminal` and `filesystem` operate inside the container by default. Access to the host requires `--privileged`, `--pid host`, or explicit volume mounts, which weakens isolation. +- **Systemd service / bare process:** Full host access natively, but harder to install and update across platforms. + +**Direction:** Provide both: Docker for sandboxed/isolated tasks, bare-metal install script for full host management. + +### 4. Authentication +Nodes must prove identity to the central server. +- Shared secret (`NODE_API_KEY`) in `.env`. +- mTLS (client certificates). +- JWT registration flow (node registers once, receives token). + +### 5. Communication Protocol +- **WebSocket outbound** (`ws://central.navi/ws/nodes/{node_id}`) for real-time task streaming. +- **REST fallback** for nodes behind restrictive proxies. +- Reuse the existing event schema (`stream_start`, `tool_started`, `stream_delta`, `stream_end`) so the central server can forward node events to the browser client unchanged. + +### 6. Lifecycle +- Node startup: register capabilities (available tools, OS, profiles) with central server. +- Heartbeat: ping every N seconds; central server marks node offline if missed. +- Graceful shutdown: close WS, release claimed recalls. + +--- + +## Decision Log + +| Date | Decision | Rationale | +|------|----------|-----------| +| 2026-05-24 | Reject browser client-side terminal | Sandbox impossibility, C2 trust issues, agent loop blocking | +| 2026-05-24 | Prefer headless node swarm | Preserves existing tool execution model, NAT-friendly, composable | + +--- + +## Next Steps (When Prioritised) + +1. Design `instance_id` database partitioning for sessions, recalls, and content. +2. Add `/ws/nodes/{node_id}` endpoint to central server for node registration and task streaming. +3. Define node-to-central auth mechanism (API key or mTLS). +4. Build minimal headless node package (Dockerfile + `.env` template). +5. Implement remote tool routing in `ToolRegistry` or as proxy tools. +6. Add node heartbeat and offline detection to `AgentSessionOrchestrator`. diff --git a/docs/archive/plan_01_god_object_agent.md b/docs/archive/plan_01_god_object_agent.md new file mode 100644 index 0000000..b338cc1 --- /dev/null +++ b/docs/archive/plan_01_god_object_agent.md @@ -0,0 +1,266 @@ +# План: Разбиение God object `navi/core/agent.py` + +**Цель:** `Agent` превращается из 1349-строчного класса, содержащего бизнес-логику, в тонкий координатор (~200–300 строк), который только связывает сервисы и передаёт события в поток. + +**Принцип:** Каждый шаг — отдельная итерация. После каждого шага тесты должны проходить. Не трогаем публичный API (`run`, `run_stream`, `run_ephemeral`) до финального шага. + +--- + +## Текущее состояние `Agent` + +``` +Agent +├── run() — blocking complete() +├── run_stream() — streaming loop + tool calling + compression + planning + stall +├── run_ephemeral() — subagent loop + timeout + thinking stall +├── _compress_session_context() — retry + hard-truncate +├── _check_context_size() +├── _estimate_context_tokens() +├── _run_workers() +├── _tool_list() +├── _get_backend() +``` + +--- + +## Шаг 1 — Выделить `ContextCompressor` + +**Что уносим:** Всё, что связано с компрессией контекста. + +- `_compress_session_context()` (строки ~700–850) — retry-логику, hard-truncate fallback +- `_check_context_size()` — можно оставить как guard, но проверку threshold перенести +- `_estimate_context_tokens()` — utility, остаётся в `Agent` или переезжает в `compressor.py` + +**Граница сервиса:** +```python +class ContextCompressor: + async def compress_session( + self, + session: Session, + llm: LLMBackend, + model: list[str], + reason: Literal["preturn", "midturn"], + keep_recent_messages: int | None = None, + ) -> ContextCompressed | None: + ... # retry + hard-truncate logic here +``` + +**Почему первый:** Уже есть `navi/core/compressor.py` — логика компрессии частично отделена. Добавляем туда retry и hard-truncate, удаляем из `Agent`. + +**Риск:** Минимальный. Уже есть тесты на компрессор и на agent context size. + +--- + +## Шаг 2 — Выделить `AgentTurnContext` + +**Что уносим:** Состояние одного пользовательского turn'а (на одну `run_stream()`). + +Текущие локальные переменные в `run_stream()`: +- `_turn_start: float` +- `_tool_call_count: int` +- `_subagent_tokens: int` +- `_turn_tokens: int` +- `_stall_no_todo: int` +- `_stall_repeat_tools: int` +- `_prev_tool_sigs: frozenset` +- `_known_failed: frozenset` +- `_replan_msg: str | None` +- `_injected_fact_ids: set[str]` +- `ctx_task / mem_facts_task` — async prefetch + +**Граница:** +```python +@dataclass +class AgentTurnContext: + turn_start: float + tool_call_count: int = 0 + turn_tokens: int = 0 + subagent_tokens: int = 0 + stall_no_todo: int = 0 + stall_repeat_tools: int = 0 + prev_tool_sigs: frozenset = field(default_factory=frozenset) + known_failed: frozenset = field(default_factory=frozenset) + replan_msg: str | None = None + injected_fact_ids: set = field(default_factory=set) +``` + +**Зачем:** Убираем 12 локальных переменных из `run_stream()`, делая метод читаемым. Контекст передаётся в выделенные сервисы (AntiStallMonitor, Compressor) вместо возврата tuple. + +--- + +## Шаг 3 — Выделить `AntiStallMonitor` + +**Что уносим:** Логику stall detection. + +- Проверка `_stall_no_todo >= threshold` и `_stall_repeat_tools >= threshold` +- Построение анти-сталл сообщения +- Отслеживание `_prev_tool_sigs` (идентичные tool calls) +- Отслеживание `_known_failed` (адаптивный реплан) + +**Граница:** +```python +class AntiStallMonitor: + def __init__(self, profile: AgentProfile): + self.profile = profile + self.stall_no_todo = 0 + self.stall_repeat_tools = 0 + self.prev_tool_sigs: frozenset = frozenset() + self.known_failed: frozenset = frozenset() + self.replan_msg: str | None = None + + async def check( + self, session_id: str, iteration: int, tool_calls: list[ToolCallRequest] + ) -> str | None: + """Returns system message to inject, or None.""" +``` + +**Зачем:** Stall detection — самостоятельная политика. Её можно тестировать отдельно от всего агента. + +--- + +## Шаг 4 — Выделить `SubAgentRunner` + +**Что уносим:** Всё из `run_ephemeral()`. + +- Tool-calling loop для subagent +- Timeout guard (`elapsed >= timeout_seconds`) +- Thinking stall detection (`_SUBAGENT_THINKING_STALL_SECONDS`) +- Context building для subagent (без persona) +- Subagent system prompt assembly + +**Граница:** +```python +class SubAgentRunner: + def __init__( + self, + agent: Agent, # или ToolExecutor + BackendRegistry + profile_registry: ProfileRegistry, + tool_registry: ToolRegistry, + backend_registry: BackendRegistry, + ctx_builder: ContextBuilder, + compressor: ContextCompressor, + ): + ... + + async def run( + self, + user_message: str, + profile_id: str, + max_iterations: int = 40, + timeout_seconds: float = 300.0, + context_transfer: str | None = None, + briefing: str | None = None, + # ... остальные параметры + ) -> tuple[str, bool]: + """Returns (result_text, success).""" +``` + +**Зачем:** `run_ephemeral` — это почти полная копия `run_stream()`, но с другими правилами (таймаут, thinking stall, нет streaming). Вынос позволяет: +- Тестировать subagent без инициализации всего агента +- Менять политику subagent независимо от основного loop'а +- Переиспользовать SubAgentRunner из других мест (например, фоновые задачи) + +--- + +## Шаг 5 — Унифицировать streaming loop + +**Что делаем:** После шагов 1–4 `run_stream()` должен уменьшиться с ~300 строк до ~80–100. + +Текущая структура `run_stream`: +``` +1. Проверка сессии +2. Pre-turn compression +3. Добавление user message +4. Planning +5. Context injections (providers, memory facts) +6. FOR iteration: + a. Check stop_event + b. Mid-turn compression + c. Goal anchor + d. Todo progress + e. Adaptive replan + f. Anti-stall + g. Check context size + h. stream_complete() + i. Accumulate tokens/thinking + j. Tool calls / save final +7. Workers +``` + +После рефакторинга: +``` +1. Проверка сессии +2. turn = AgentTurnContext() +3. compressor.maybe_compress_preturn(session) +4. Добавление user message +5. planning.run(...) +6. context = ctx_builder.build(...) +7. FOR iteration: + a. compressor.maybe_compress_midturn(session, iteration) + b. anti_stall.check(...) + c. goal_anchor + d. todo_progress + e. stream_complete() + f. turn.accumulate(chunk) + g. tool calls / save final +8. workers.run(...) +``` + +Все внутренние переменные (token count, stall counters) живут в `turn`. Логика каждой фичи — в своём сервисе. + +--- + +## Шаг 6 — Очистить `Agent.run()` + +**Что делаем:** `run()` сейчас содержит копию tool-calling loop, отличную от `run_stream()`. После выноса `SubAgentRunner` можно переиспользовать его логику для `run()`: + +```python +async def run(self, session_id, user_message): + """Blocking variant — collects stream into string.""" + result = "" + async for event in self.run_stream(session_id, user_message): + if isinstance(event, StreamEnd): + result = event.full_content + return result +``` + +Убираем полную копию loop'а. Единственная разница — `run()` не стримит, но внутри вызывает `run_stream()` и дропает промежуточные события. + +--- + +## Шаг 7 — Финальная очистка и тесты + +- Убрать мёртвый код (оставшиеся приватные методы, которые переехали) +- Проверить, что публичный API не изменился +- Запустить полный regression: `pytest tests/ -x --tb=short --ignore=tests/integration/test_websocket.py` +- Обновить `docs/architecture_weak_spots.md` — отметить пункт 1 как выполненный + +--- + +## Ожидаемый результат + +``` +navi/core/ +├── agent.py ~250 строк (координатор) +├── agent_run_context.py ~50 строк (dataclass + helpers) +├── compressor.py ~200 строк (уже существует, + retry/hard-truncate) +├── anti_stall.py ~80 строк (новый) +├── subagent_runner.py ~180 строк (новый) +├── planning.py ~150 строк (уже существует) +├── context_builder.py ~120 строк (уже существует) +├── tool_executor.py ~80 строк (после DRY-фикса) +``` + +--- + +## Порядок работы + +1. Шаг 1 — ContextCompressor (retry + hard-truncate в compressor.py) +2. Шаг 2 — AgentTurnContext (refactor run_stream locals) +3. Шаг 3 — AntiStallMonitor +4. Шаг 4 — SubAgentRunner +5. Шаг 5 — Унификация run_stream (после готовности сервисов) +6. Шаг 6 — run() через run_stream() +7. Шаг 7 — Финальная очистка + +Каждый шаг — отдельный коммит. Тесты должны проходить после каждого. diff --git a/docs/archive/tech_debt_review_2026-04-29.md b/docs/archive/tech_debt_review_2026-04-29.md new file mode 100644 index 0000000..ec81c72 --- /dev/null +++ b/docs/archive/tech_debt_review_2026-04-29.md @@ -0,0 +1,109 @@ +# Code Review — Technical Debt & Stability Issues + +**Date:** 2026-04-29 +**Scope:** Full backend + frontend review +**Total items:** 53 issues + +Security items are listed in a separate deferred section at the end of this document. They are **not** in scope for the current fix pass but must be addressed before any multi-user or public-facing deployment. + +--- + +## Critical — Stability / Data Loss / Crash + +| # | Problem | File | Line | Explanation | +|---|---------|------|------|-------------| +| 1 | **[FIXED] Race condition: concurrent runs overwrite each other** | `navi/api/websocket.py` | 70, 328-331, 164 | `_runs[session_id] = run` has no guard. Two tabs or rapid reconnects for the same session overwrite each other. The first run's `finally` block calls `_runs.pop()`, which accidentally removes the *second* run. Cooperative stop signals break; zombie tasks remain. | +| 2 | **[FIXED] Resource leak: tool tasks never cancelled on generator teardown** | `navi/core/agent.py` | 842-892 | Each tool call runs in `asyncio.create_task()`. If the consumer (WebSocket handler) is cancelled, `GeneratorExit` breaks the `while True` loop at `yield item` but `tool_task` is never awaited or cancelled. It continues executing filesystem/SSH/shell commands in the background with no way to stop it. | +| 3 | **[FIXED] Empty LLM stream silently kills fallback** | `navi/llm/fallback.py` | 162-165, 208-210 | `stream()` and `stream_complete()` catch `StopAsyncIteration` from an empty generator and do `return`, ending the entire fallback chain instead of trying the next server. User sees an empty response with no error. | +| 4 | **[FIXED] Frontend race condition in session loading** | `webclient/src/stores/chat.js` | 34-58 | `loadSession()` guards against reloading the *same* ID, but has no guard against two *different* concurrent loads. If user switches A→B while `loadSession(A)` is in-flight, the late completion overwrites `currentId` back to A. UI snaps to the wrong session. | +| 5 | **[FIXED] Frontend crash on non-string tool result** | `webclient/src/components/messages/ContentCard.vue` | 100-111 | `metadata` computed property calls `.match()` on `props.tool.result ?? ''`. If `result` is an object or `null`, `.match()` throws `TypeError` and crashes the component. | +| 6 | **[FIXED] Frontend crash on malformed image data** | `webclient/src/stores/chat.js` | 393 | `buildMessageList` calls `b.startsWith('data:')` on every element of `m.images`. If backend sends `null` or a non-string, the history builder crashes. | + +--- + +## High — Performance / Reliability / UX Breakage + +| # | Problem | File | Line | Explanation | +|---|---------|------|------|-------------| +| 7 | **[FIXED] Unbounded WebSocket replay buffer** | `navi/api/websocket.py` | 44-45 | `_AgentRun.events` is a plain `list[dict]` with no size limit. Large tool results (e.g. 5 MB terminal output) accumulate. Every reconnect replays the entire buffer, causing memory and bandwidth spikes. | +| 8 | **[REMOVED] SQLite opens a new connection per operation** | `navi/core/sqlite_session_store.py` | 68, 79, 89, 101, 111, 121, 129 | SQLite support was removed entirely. PostgreSQL is now the only supported database. `aiosqlite` dependency removed, `db_path` config removed. | +| 9 | **[FIXED] Memory extraction task storm** | `navi/api/routes/sessions.py` | 50 | Every `POST /sessions` spawns a fire-and-forget `asyncio.create_task(_process_stale_sessions(...))`. No deduplication or rate-limiting. Rapid session creation launches many concurrent LLM calls, overwhelming the backend. | +| 10 | **[FIXED] Permanent blacklisting with no recovery** | `navi/llm/fallback.py` | 38-39 | `_dead_servers` and `_dead_models` are module-level sets. A transient network blip permanently blacklists the server until Python process restart. | +| 11 | **[FIXED] Subagent exception isolation missing** | `navi/core/agent.py` | 512 | Inside `run_ephemeral()`, tools execute with bare `await tool.execute()`. No `_run_with_sentinel` wrapper. If a tool crashes, the subagent dies and all partial progress is lost. | +| 12 | **[FIXED] No size validation on inline images** | `navi/api/websocket.py` | 297-304 | `raw_images` is taken directly from client JSON with no length or size checks. A client can send massive base64 payloads, causing memory exhaustion. | +| 13 | **[FIXED] Orphaned files when DB insert fails** | `navi/content_store.py` | 114-129 | In `publish()`, `shutil.copy2` runs first. If the subsequent DB `INSERT` fails (caught and logged), the copied file remains on disk with no metadata record, accumulating orphaned files in `navi/content/`. | +| 14 | **[FIXED] Frontend: broken streaming auto-scroll** | `webclient/src/components/chat/MessageList.vue` | 76-89 | Watchers on `chat.streamingMsg?.text` never fire during streaming because `streamingMsg` is a `shallowRef`. Vue does not track deep mutations, so the chat list does not auto-scroll as new deltas arrive. | +| 15 | **[FIXED] Frontend: mixed-content WebSocket on HTTPS** | `webclient/src/composables/useWebSocket.js` | 4-6 | WS URL is hardcoded to `ws://${location.host}`. When served over HTTPS, browsers block `ws://` as mixed content. Must switch to `wss://` when `location.protocol === 'https:'`. | +| 16 | **[FIXED] Frontend: duplicate message sends from rapid clicks** | `webclient/src/components/chat/InputBar.vue` | 141-160 | `handleSend` has no debounce or in-flight guard. `canSend` only checks `!chat.streaming`, which is not set until `stream_start`. Rapid clicks can queue multiple identical messages. | +| 17 | **[FIXED] Frontend: unhandled promise rejections across API layer** | `webclient/src/api/index.js` and callers | — | `request()` and `uploadFile()` throw on errors, but almost no callers wrap their awaits in `try/catch`. Failures become unhandled rejections and leave the UI in a broken silent state. | +| 18 | **SSH connections not closed on shutdown** | `navi/tools/ssh_exec.py` | 54-90 | `_PoolEntry` connections hang until TTL or process kill. No graceful cleanup on server shutdown. | + +--- + +## Medium — Architectural / Edge Cases / Maintainability + +| # | Problem | File | Line | Explanation | +|---|---------|------|------|-------------| +| 19 | **[FIXED] N+1 UPDATE queries in embedding backfill** | `navi/memory/store.py` | 170-176 | `backfill_embeddings()` loops over rows, issuing one `UPDATE` per row. Should batch via `executemany` or multi-row `UPDATE`. | +| 20 | **[FIXED] Unindexed ILIKE fallback search** | `navi/memory/store.py` | 282-314 | Text fallback constructs dynamic SQL with `ILIKE` on `category`, `key`, `value`. No indexes exist on these columns individually. Full table scan on large tables. | +| 21 | **[FIXED] Context list rebuilt from scratch every iteration** | `navi/core/agent.py` | ~683 | `_build_context()` creates a brand-new list by filtering and prepending system messages on every tool-calling iteration. O(n) per iteration, generating significant garbage. | +| 22 | **[FIXED] Unbounded `planning_logs` growth** | `navi/core/agent.py` | 651 | `session.planning_logs.append(_ev.log)` with no size limit or rotation. Long sessions with planning enabled produce huge JSON in DB, slowing `save()`. | +| 23 | **[FIXED] Background cleanup task unreferenced** | `navi/main.py` | 72 | `asyncio.create_task(cleanup_loop(...))` — the returned `Task` is not stored. In Python < 3.11, unhandled exceptions may be silently discarded. | +| 24 | **[FIXED] BaseException catches SystemExit** | `navi/core/agent.py` | 847-848 | `_run_with_sentinel` catches `BaseException`, which includes `SystemExit`. Should catch only `Exception`. | +| 25 | **[FIXED] SystemExit swallowed in agent loop** | `navi/core/agent.py` | 871 | `isinstance(r, BaseException)` includes `SystemExit`. It should propagate to allow graceful server shutdown. | +| 26 | **[FIXED] Hardcoded context output reserve** | `navi/core/agent.py` | 1521 | `output_reserve = 2048` tokens is not configurable per model. May falsely reject valid contexts for small models. | +| 27 | **[FIXED] Vector literal NaN/Infinity vulnerability** | `navi/memory/store.py` | 73-75 | `_vector_to_str` converts floats naively. A misbehaving embedding model returning `NaN` or `Infinity` creates invalid PostgreSQL vector syntax. | +| 28 | **Tight coupling to todo module internals** | `navi/core/agent.py` | 176-224 | `_todo_status_snapshot` directly reads `navi.tools.todo._plans`, a module-level dict. Hard to test and refactor. | +| 29 | **ContextVar values not reset in subagent path** | `navi/core/agent.py` | 350-351 | `run_ephemeral()` sets `_sid_var` and `_model_var` but never resets them. Background tasks spawned by the subagent may inherit stale values. | +| 30 | **[FIXED] Replay/reattach may miss post-finish events** | `navi/api/websocket.py` | 274-277 | If a client reconnects after a run finishes but before disconnect is fully processed, it gets `session_sync`. No push event indicates new messages were persisted while disconnected. | +| 31 | **[FIXED] Frontend: singleton confirm-dialog promise leak** | `webclient/src/composables/useConfirm.js` | 5-30 | Module-level `_resolve`. If `confirm()` is called twice before the first resolves, the second overwrites `_resolve`, causing the first promise to hang forever. | +| 32 | **[FIXED] Frontend: body overflow leak on lightbox unmount** | `webclient/src/components/ui/ImageLightbox.vue` | 26-28 | `watch(src)` sets `document.body.style.overflow = 'hidden'`. If component unmounts while open, the side effect is never reverted. Page stays permanently unscrollable. | +| 33 | **[FIXED] Frontend: DOM leak on fallback copy failure** | `webclient/src/composables/useCopy.js` | 10-17 | Fallback copy appends a `