Compare commits
27 commits
antigravit
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3235239d5b | ||
|
|
2afd95fe3f | ||
|
|
c1ba34d369 | ||
|
|
377c567b85 | ||
|
|
d45c36c433 | ||
|
|
fc4707d9d9 | ||
|
|
372be71de7 | ||
|
|
713441ae39 | ||
|
|
ec656a0e08 | ||
|
|
922c437689 | ||
|
|
1b1143feeb | ||
|
|
1fe4549b22 | ||
|
|
7fb8c6a6c5 | ||
|
|
61e7933334 | ||
|
|
eac8352dc2 | ||
|
|
a3373f9f76 | ||
|
|
7136ab2878 | ||
|
|
89435eadb5 | ||
|
|
8b67f0dadb | ||
|
|
93da1b22fd | ||
|
|
144f6a5d59 | ||
|
|
c6981921da | ||
|
|
c0485a3721 | ||
|
|
d39189ee8e | ||
|
|
2f353778c2 | ||
|
|
d2307362b5 | ||
|
|
ef00a64f93 |
44 changed files with 4820 additions and 231 deletions
23
.github/workflows/ci.yml
vendored
23
.github/workflows/ci.yml
vendored
|
|
@ -6,11 +6,22 @@ on:
|
|||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
# Матрица из двух систем.
|
||||
#
|
||||
# Обе джобы стояли на windows-latest, и это дорого обошлось: инвариант A37 не
|
||||
# держался на Windows, а тесты установки и остановки процессов молча
|
||||
# предполагали Linux. Прогон на одной системе не показывал ни того, ни другого.
|
||||
# Проект работает на Linux и активно получает Linux-правки, поэтому обе системы
|
||||
# проверяются одинаковым набором.
|
||||
jobs:
|
||||
test:
|
||||
name: Clean Windows Runner Test
|
||||
runs-on: windows-latest
|
||||
name: Clean Runner Test (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
|
@ -40,9 +51,13 @@ jobs:
|
|||
python scripts/release_gate.py
|
||||
|
||||
headless:
|
||||
name: Headless Run (no GUI dependencies)
|
||||
runs-on: windows-latest
|
||||
name: Headless Run (${{ matrix.os }}, no GUI dependencies)
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
|
|
|||
24
.github/workflows/release.yml
vendored
24
.github/workflows/release.yml
vendored
|
|
@ -57,6 +57,21 @@ jobs:
|
|||
$manifest | ConvertTo-Json -Depth 5 | Out-File -FilePath "$distDir/update_manifest.json" -Encoding utf8
|
||||
Write-Host "Generated update_manifest.json with SHA256: $hash"
|
||||
|
||||
# Набор проверяется ДО публикации.
|
||||
#
|
||||
# update_manager ищет в релизе строго HermesHubSetup.exe или
|
||||
# hermes-hub-setup.sh, а шаг выше собирает только zip и манифест. Такой
|
||||
# релиз становится "latest", и любая попытка обновиться отвечает «в
|
||||
# релизе не найден подходящий файл обновления для текущей платформы».
|
||||
#
|
||||
# Раньше это не проявлялось лишь потому, что весь конвейер падал на шаге
|
||||
# Release Gate — на тех же двух дефектах, что и CI; ни один его прогон не
|
||||
# доходил до публикации, а релизы выкладывались мимо него. Как только
|
||||
# тесты позеленели, случайная защита исчезла.
|
||||
- name: Built assets must be installable by the updater
|
||||
run: |
|
||||
python scripts/release_gate.py --assets dist
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
|
|
@ -68,3 +83,12 @@ jobs:
|
|||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Ворота публикации: проверяют опубликованный релиз, а не сборку.
|
||||
# Релиз есть, ассеты есть, пакет скачан целиком, SHA-256 сошёлся с
|
||||
# опубликованным checksums.txt. Здесь они блокируют: раньше эта проверка
|
||||
# возвращала PASS при обрыве сети, при 404 на манифест и при 404 на
|
||||
# пакет, то есть пропускала релиз при любом исходе.
|
||||
- name: Publication Gate (published release must be verifiable)
|
||||
run: |
|
||||
python scripts/release_gate.py --publication-only
|
||||
|
|
|
|||
114
agents/done/2026-08-26-A30-release-gate-report.md
Normal file
114
agents/done/2026-08-26-A30-release-gate-report.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# Отчёт независимого оркестратора: Release Gate ветки `codex/workflow-canvas` (A30)
|
||||
|
||||
## Дата проведения
|
||||
2026-08-26
|
||||
|
||||
## Объект аудита
|
||||
- **Ветка:** `codex/workflow-canvas`
|
||||
- **Цель:** Независимая проверка реализации задания A30 («Главный экран "Обзор" — граф workflow, файлы агентов, LIVE»).
|
||||
|
||||
---
|
||||
|
||||
## 1. Сводка Git и состояние репозитория
|
||||
|
||||
- **`START_HEAD` (базовый коммит / merge-base с `main`):** `d6ec34d482a4e00a2017c7b53e934a82df0cc5ad`
|
||||
- **`FINAL_HEAD` (коммит ветки A30):** `0c19738e29683c215352ea9de1b68a0a2e95b1f8` (`feat(web): add workflow canvas and live agent workspace`)
|
||||
- **`origin/main`:** `c35bc4868d62cfa7abb7a1a4c1c17eca51eb6ce5`
|
||||
- **Состояние рабочей директории (`git status`):**
|
||||
- Нестажированные изменения в бинарниках и установщике (`installer/HermesHubSetup.cs`, `launcher/HermesHub.exe`, `launcher/HermesHubWeb.exe`).
|
||||
- Нестажированный фикс CORS в `src/antigravity_provider/router/web/server.py` (перенесённый из `c35bc48` на `main`).
|
||||
- Неотслеживаемые задания в inbox (`agents/inbox/2026-08-25-A32-remove-desktop.md`, `agents/inbox/2026-08-26-antigravity-release-gate-a30.md`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Результаты детерминированных проверок
|
||||
|
||||
### 2.1. Линтер `ruff check .`
|
||||
- **Результат:** `All checks passed!` (0 ошибок, 0 предупреждений).
|
||||
|
||||
### 2.2. Полный регрессионный сьют `pytest tests/ -v`
|
||||
- **Результат:** **458 passed, 2 skipped, 3 deselected, 1 failed** (всего 461 тест).
|
||||
- **Время прогона:** 86.74 сек.
|
||||
|
||||
### 2.3. Скрипт `scripts/release_gate.py`
|
||||
- **Результат:** `[RELEASE GATE: FAILED] One or more checks failed. Release blocked.`
|
||||
- **Причина:** Падение теста обратной совместимости `tests/test_web_parity_a21.py::test_web_client_html_and_js_7_views_parity`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Реестр найденных дефектов
|
||||
|
||||
| ID | Приоритет | Компонент | Описание дефекта и минимальное воспроизведение |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **DEF-01** | **P1** | `tests/test_web_parity_a21.py:133` | **Устаревшая проверка селектора в тестах регрессии.** Тест проверяет наличие старого контейнера `overview-route-diagram` в `index.html`. В рамках A30 главный экран «Обзор» был полностью перестроен в Workflow Canvas (`workflow-canvas`, `workflow-main-layout`), и старый селектор был правомерно удалён из разметки, но тест не был обновлён под новый layout A30. <br>**Воспроизведение:** `pytest tests/test_web_parity_a21.py -k test_web_client_html_and_js_7_views_parity`. |
|
||||
| **DEF-02** | **P2** | `server.py` / `git` | **Отставание ветки от `origin/main`.** Ветка `codex/workflow-canvas` ответвлена от `d6ec34d` и не включает коммит безопасности `c35bc48` (`fix(security): любой сайт во вкладке рядом мог управлять хабом`). Перед финальным слиянием в `main` требуется rebase / merge с актуальным `main`. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Результаты проверки подсистем A30
|
||||
|
||||
### P0-1. Модель агента и Agent File
|
||||
- **Статус:** **PASS**
|
||||
- Сервис `WorkflowService` в [`workflow_service.py`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/src/antigravity_provider/router/workflow_service.py) реализует полное управление жизненным циклом агентов: `create_agent`, `update_agent`, `delete_agent`.
|
||||
- Роли роутера автоматически мигрируют в сущности агентов.
|
||||
- Файлы агентов создаются физически на диске в `agents/{role}.md` (например, `agents/orchestrator.md`, `agents/coder-primary.md`) и содержат реальный Markdown.
|
||||
- Удаление агента, задействованного в ребрах графа, требует явного подтверждения (`confirmation_required: True`), предотвращая повреждение графа.
|
||||
- Проверено тестами: `test_router_roles_migrate_to_agents_and_create_real_files`, `test_create_update_file_and_restart_persistence`, `test_delete_requires_explicit_confirmation_when_referenced`.
|
||||
|
||||
### P0-2. Граф workflow (Canvas, EDIT/LIVE, Циклы)
|
||||
- **Статус:** **PASS**
|
||||
- Граф реализован на чистом SVG + HTML5 (без npm, без react, без сторонних зависимостей сборки) в [`workflow.js`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/src/antigravity_provider/router/web/static/workflow.js) и [`workflow.css`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/src/antigravity_provider/router/web/static/workflow.css).
|
||||
- **Режимы:** Чёткое переключение между `LIVE` (мониторинг исполнения) и `EDIT` (редактирование графа, соединение портов).
|
||||
- **Редактор ребра:** Модальное окно позволяет задавать условия переходов (`SUCCESS`, `REVIEW_PASSED`, `REVIEW_FAILED`) и подписи.
|
||||
- **Поддержка циклов:** Циклические маршруты (`Кодер 1 → Ревьюер → Кодер 1`) поддержаны и валидируются.
|
||||
- **Защита от бесконечного зацикливания:** Параметр `max_iterations` отображается на экране (например, `Итерация: 2 / 5`), сохраняется в конфигурации и принудительно останавливает цикл с генерацией явного события `WORKFLOW_MAX_ITERATIONS`.
|
||||
- **Элементы управления:** Мини-карта, масштабирование (`- 100% + ⛶`), легенда состояний узлов и рёбер.
|
||||
|
||||
### P0-3. LIVE-мониторинг, события и обработка ошибок
|
||||
- **Статус:** **PASS**
|
||||
- Поддержаны 5 состояний агента: `Ожидает` (серый), `Работает` (синий), `Проверяет` (жёлтый), `Ошибка` (красный), `Завершено` (зелёный).
|
||||
- Тексты реальных ошибок провайдеров (например, `No authentication token found for Codex profile 'codex-orch'`) доходят до статуса запуска и списка событий.
|
||||
- Прерванный перезапуском прогон корректно помечается статусом `interrupted` с записью события `WORKFLOW_INTERRUPTED`.
|
||||
- Проверено тестами: `test_live_cycle_stops_with_explicit_iteration_limit_event`, `test_interrupted_run_is_reported_not_silently_completed`, `test_provider_error_text_reaches_run_and_events`.
|
||||
|
||||
### P0-4. Честность данных (Zero Fake / Zero Mock)
|
||||
- **Статус:** **PASS**
|
||||
- Поиск по кодовой базе показал полное отсутствие захардкоженных демонстрационных чисел из макета (`12`, `3.42 с`, `1.42M`, `94.2%`, `42`, `account-01...`).
|
||||
- Все 5 оперативных KPI-показателей на экране «Обзор» берутся из реальных источников:
|
||||
1. *Активные задачи:* `workflow.run.status` (0 или 1).
|
||||
2. *Агенты онлайн:* `readiness.roles_ready_count / readiness.total_roles` из сервиса `readiness`.
|
||||
3. *Среднее время ответа:* `telemetry.global.latency_p50_ms` (при отсутствии вызовов: `Н/Д: за 24 часа нет измеренных вызовов`).
|
||||
4. *Использование токенов:* `telemetry.global.total_tokens` (при отсутствии: `Н/Д: провайдеры не вернули usage`).
|
||||
5. *Успешность задач:* отношение `successful_calls / total_calls` (при отсутствии: `Н/Д: за 24 часа нет завершённых вызовов`).
|
||||
- Состояния загрузки (`workflow.is_loading`) явно отделены от отсутствия данных.
|
||||
|
||||
### P0-5. Неприкосновенность десктопного UI
|
||||
- **Статус:** **PASS**
|
||||
- Проверка `git diff --stat d6ec34d 0c19738 -- src/antigravity_provider/router/ui` подтвердила **0 изменений** в каталоге `router/ui/**`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Проверка артефактов и скриншотов
|
||||
|
||||
Все 5 обязательных скриншотов присутствуют в каталоге `docs/screenshots/a30/` и проверены:
|
||||
1. [`overview-live.png`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/docs/screenshots/a30/overview-live.png) — Главный экран в режиме LIVE с 6 агентами, честными статусами «Н/Д» и мини-картой.
|
||||
2. [`overview-edit-inspector.png`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/docs/screenshots/a30/overview-edit-inspector.png) — Режим EDIT с выбранным узлом «Кодер 1», портами соединения и панелью инспектора (вкладки Основное, Модель, Инструкции, Инструменты, Память).
|
||||
3. [`edge-editor.png`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/docs/screenshots/a30/edge-editor.png) — Модальное окно создания/редактирования ребра (`coder-primary` → `reviewer`, условие `SUCCESS`).
|
||||
4. [`agent-file-editor.png`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/docs/screenshots/a30/agent-file-editor.png) — Редактор файла агента `agents/coder-primary.md` с реальным содержимым.
|
||||
5. [`overview-live-provider-error.png`](file:///c:/Users/Ochenstarik/Agent_projects/hermes-hub/docs/screenshots/a30/overview-live-provider-error.png) — Отображение реальной ошибки провайдера в узле «Главный оркестратор» (красный статус) и в журнале событий LIVE.
|
||||
|
||||
---
|
||||
|
||||
## 6. Пропущенные проверки
|
||||
- **Пропущенных проверок нет.** Все 10 пунктов регламента выполнены в полном объёме.
|
||||
|
||||
---
|
||||
|
||||
## 7. Итоговый вердикт Release Gate
|
||||
|
||||
> **ВЕРДИКТ: `BLOCKED` (Требуется исправление 1 теста и Rebase)**
|
||||
|
||||
**Обоснование:**
|
||||
1. Функциональная реализация A30 (`WorkflowService`, Canvas, Agent Files, LIVE/EDIT, Cycle limits, Data honesty) выполнена качественно и полностью соответствует ТЗ.
|
||||
2. Автоматический Release Gate заблокирован из-за дефекта **DEF-01** (устаревший ассерт `overview-route-diagram` в `tests/test_web_parity_a21.py:133`), дающего 1 падение из 461 теста.
|
||||
3. Ветка требует rebase на актуальный `origin/main` (включение фикса безопасности CORS **DEF-02**) и обновления теста `test_web_parity_a21.py` на селектор `workflow-canvas`.
|
||||
248
agents/done/2026-09-02-HUB1-audit-p0-green-main.md
Normal file
248
agents/done/2026-09-02-HUB1-audit-p0-green-main.md
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
# Отчёт HUB-1: зелёный main и P0 из аудита
|
||||
|
||||
## Сдача
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Ветка | `hub/audit-p0-green-main` |
|
||||
| `START_HEAD` | `93da1b22fd2e0385f46b71a1e220fa1e4e716545` |
|
||||
| Последний рабочий коммит | `f061961600dd2eb3d6901cc154dbf5a82456dd14` |
|
||||
| `origin/main` на момент сдачи | `93da1b2` (не двигался) |
|
||||
| PR | https://github.com/ochenstarik-ui/hermes-hub/pull/2 |
|
||||
| Зелёный прогон CI | https://github.com/ochenstarik-ui/hermes-hub/actions/runs/33729660525 |
|
||||
| `git status` | чисто (вне репозитория лежит посторонний `gyoza_shorts.mp4`, не мой и не тронут) |
|
||||
|
||||
### Зелёный CI — все четыре джоба
|
||||
|
||||
| Джоб | Итог |
|
||||
|---|---|
|
||||
| Clean Runner Test (windows-latest) | **pass** |
|
||||
| Clean Runner Test (ubuntu-latest) | **pass** |
|
||||
| Headless Run (windows-latest) | **pass** |
|
||||
| Headless Run (ubuntu-latest) | **pass** |
|
||||
|
||||
`ruff check .` — чисто. Release Gate — PASSED на обеих системах.
|
||||
|
||||
Локально (Linux): **778 passed, 2 skipped, 4 deselected**. База до работы —
|
||||
739 passed, 2 skipped. Число тестов выросло на 39, ни один не удалён.
|
||||
|
||||
---
|
||||
|
||||
## P0-1. Зелёный main
|
||||
|
||||
### Обе причины из задания подтвердились — и обе оказались шире описания
|
||||
|
||||
**1. Инвариант A37 не держался на Windows.** Причина именно та, что
|
||||
предполагалась. Воспроизведено локально на окружении Windows (нет переменной
|
||||
`HOME`): `os.path.expandvars("$HOME/.hermes")` оставляет строку как есть, путь
|
||||
перестаёт быть абсолютным, склеивается с каталогом проекта и оказывается
|
||||
«внутри разрешённого корня» — команда проходит.
|
||||
|
||||
Заодно нашлась **зеркальная дыра, в задании не названная**: на Linux так же
|
||||
проходили `rm -rf %USERPROFILE%\.hermes` и `del /f /q C:\Windows\System32`.
|
||||
`Path("C:/Windows").resolve()` на Linux приписывает пути текущий каталог, и
|
||||
удаление системного каталога Windows выглядело работой внутри проекта.
|
||||
|
||||
Разбор пути сведён в один конвейер, как и требовало задание: классификация
|
||||
диалекта shell **по самой команде, а не по системе-хозяину** → раскрытие
|
||||
распознанных переменных, с разрешением `HOME`/`USERPROFILE` в домашний каталог
|
||||
даже когда их нет в окружении → нормализация разделителей → канонизация →
|
||||
сравнение с защищёнными корнями. Каждый несостоявшийся шаг **закрывает
|
||||
проход**: непроверяемый путь не считается разрешённым. Через тот же конвейер
|
||||
пропущены `validate_path`, `is_forbidden_path`, `is_inside_allowed_root`.
|
||||
|
||||
Доказательство: новые тесты воспроизводят окружение обеих систем на любой из
|
||||
них. На прежнем guard они падают — **6 failed**, ровно на дефекте из CI и на
|
||||
зеркальных случаях; на новом проходят. `test_a37_isolation_guards` зелёный на
|
||||
Windows-раннере.
|
||||
|
||||
**2. UTF-8 ронял verification-скрипт.** Воспроизведено точно: строка 63, тот же
|
||||
`UnicodeEncodeError`. Общий помощник `console_encoding.force_utf8_output`
|
||||
ставит UTF-8 на потоки и оставляет запасной путь, если поток перекодировать
|
||||
нельзя. Той же реализацией заменён самодельный блок в `cli_commands`.
|
||||
Скрипт проходит **10/10** под `PYTHONIOENCODING=cp1252` и под `ascii`.
|
||||
|
||||
### Причин красного CI было не две, а семь
|
||||
|
||||
Это главное расхождение с заданием. Ревьюер видел две; живой прогон на
|
||||
Windows после их устранения показал ещё пять. Четыре из них — **не дефекты
|
||||
продукта, а допущения тестов, зашитые под Linux**:
|
||||
|
||||
1. `test_a41` читал вывод скрипта в кодировке системы. Скрипт стал писать
|
||||
UTF-8, а родитель на Windows читал трубу как cp1252 и разваливался на
|
||||
`UnicodeDecodeError`, оставляя `proc.stdout` равным `None`. Кодировка
|
||||
задана явно с обеих сторон трубы.
|
||||
2. `test_p0_3_stop_running_hub` знал только про ветку Linux (`os.kill` по
|
||||
списку от `pgrep`). На Windows процессы останавливает `taskkill` по списку
|
||||
от `wmic`. Инвариант один — «чужой процесс останавливается, свой PID не
|
||||
трогаем» — теперь проверяется на обеих ветках.
|
||||
3-4. Оба теста установки подсовывали bash-скрипт `hermes-hub-setup.sh`; на
|
||||
Windows выбирается `HermesHubSetup.exe`, и установка честно отвечала «в
|
||||
релизе не найден подходящий файл обновления». Установщик берётся под ту
|
||||
систему, на которой идёт прогон. Проверка сообщения смотрит, назван ли код
|
||||
возврата, а не на склонение: ветки формулируют «код 3» и «кодом 3».
|
||||
|
||||
Пятая — моя собственная: добавленный русский вывод Release Gate уронил шаг
|
||||
на cp1252. Тот же класс дефекта, то же лекарство.
|
||||
|
||||
Шестая — **флейк, из-за которого main краснел случайно**: базовый прогон до
|
||||
начала работы дал то 738, то 739. Причина найдена по падению ubuntu-джоба:
|
||||
`test_seq_token_prevents_stale_refresh_clobber` сравнивал поколения до и после
|
||||
устаревшего вызова, а `HubStateStore` — процессный синглтон, и фоновый сборщик
|
||||
квот от другого теста успевает поднять `generation` между вызовами. Теперь
|
||||
проверяется инвариант (устаревший ответ отброшен ровно один раз, состояние
|
||||
назад не откатывается), а не равенство. Пять полных прогонов со случайным
|
||||
порядком — 777 passed.
|
||||
|
||||
---
|
||||
|
||||
## P0-2. Остальные P0 аудита — каждый подтверждён исполнением
|
||||
|
||||
### 1. Release Gate заявлял проверку хеша, которой не было — **подтвердилось**
|
||||
|
||||
Хуже, чем в аудите. `hashlib` в `scripts/release_gate.py` **не вызывался ни
|
||||
разу**: скачивались байты 0-10 через заголовок `Range`, и этого хватало, чтобы
|
||||
напечатать `PACKAGE_HASH_VERIFIED=True`. «Проверенным ассетом» при этом
|
||||
оказывался первый в списке — `checksums.txt`, а не пакет.
|
||||
|
||||
### 2. Publication gate fail-open — **подтвердилось**, во всех трёх условиях
|
||||
|
||||
Измерено прогоном самой функции:
|
||||
|
||||
| Условие | Прежний вердикт |
|
||||
|---|---|
|
||||
| Полный обрыв сети | **PASS** |
|
||||
| Манифест 404 (релиза нет) | **PASS** |
|
||||
| Пакет 404 (ассет не загружен) | **PASS** |
|
||||
|
||||
Ворота пропускали релиз при любом исходе, включая полное отсутствие релиза.
|
||||
|
||||
Разделено, как требовало задание: офлайновая часть (версии, тесты, updater,
|
||||
статика, секреты, список разрешённых адресов) блокирует всегда; Publication
|
||||
Gate проверяет, что релиз есть, ассеты есть, пакет скачан **целиком** и
|
||||
SHA-256 сошёлся с опубликованным `checksums.txt`. Блокирует в режиме
|
||||
публикации (`--publication` или `HERMES_RELEASE_PUBLICATION_GATE=1`); в
|
||||
обычном прогоне CI, где релиза для ветки нет и быть не должно, результат
|
||||
сообщается как есть и не блокирует. Неизмеренное называется причиной, а не
|
||||
выдаётся за проверенное.
|
||||
|
||||
Проверено на живом релизе `v0.1.3-b1`: два пакета скачаны целиком, хеши
|
||||
сошлись. Проверено на отказах: обрыв сети и 404 теперь **FAIL**.
|
||||
|
||||
### 3. localhost `/api/action` без CSRF — **подтвердилось**
|
||||
|
||||
CORS уже закрыт правкой ревьюера, но CORS мешает **прочитать** ответ, а не
|
||||
**отправить** запрос. Измерено на конфигурации по умолчанию
|
||||
(`web_api_host=127.0.0.1`): POST с `Content-Type: text/plain` уходит
|
||||
кросс-сайтом без предварительного запроса (простой запрос по правилам CORS), а
|
||||
`request.json()` разбирает тело независимо от `Content-Type`. Запрос с
|
||||
`Origin: https://evil.example.com` и без токена доходил до исполнителя
|
||||
действий — отвечало уже само действие. Среди доступных действий
|
||||
`clear_accounts`, `delete_credentials`, `set_main`.
|
||||
|
||||
Проверяется `Sec-Fetch-Site`, при его отсутствии — `Origin` против адреса
|
||||
запроса. Замер после правки:
|
||||
|
||||
| Запрос | Итог |
|
||||
|---|---|
|
||||
| чужой сайт, `Sec-Fetch-Site: cross-site` | **403** |
|
||||
| чужой сайт, старый браузер (только `Origin`) | **403** |
|
||||
| собственный интерфейс | 200 |
|
||||
| адресная строка / расширение | 200 |
|
||||
| не-браузерный клиент (curl, CLI) | 200 |
|
||||
|
||||
Защита распространена на все пять небезопасных методов, не только на
|
||||
`/api/action`.
|
||||
|
||||
---
|
||||
|
||||
## P1
|
||||
|
||||
1. **Zip-slip — НЕ ВОСПРОИЗВОДИТСЯ.** Это единственное расхождение с аудитом
|
||||
по существу, и оно в пользу продукта. Архив с `../`, с абсолютным путём и с
|
||||
записью-ссылкой распакован через `zipfile.extractall`: ничего за пределы
|
||||
каталога не вышло, абсолютный путь стал относительным, `../` схлопнулись, а
|
||||
запись-ссылка легла обычным файлом. CPython санирует пути сам.
|
||||
|
||||
Но это свойство реализации, а не обещание формата, и распаковка идёт в
|
||||
корень установки. Граница сделана собственным инвариантом: каждая запись
|
||||
проверяется до записи на диск, отклоняются абсолютные пути, выход через
|
||||
`..`, ссылки и записи не-файлового типа. Инвариант закреплён тестом, а не
|
||||
оставлен на усмотрение стандартной библиотеки.
|
||||
|
||||
2. **pricing fallback** — исправлено: `safe_load` вместо `safe_dump`. `dump`
|
||||
сериализовал текст обратно в строку, `isinstance(data, dict)` не
|
||||
выполнялось никогда, таблица цен не загружалась ни разу, а `except` это
|
||||
глушил.
|
||||
|
||||
3. **CI-матрица Windows + Linux** — сделано, обе джобы. Именно отсутствие
|
||||
Linux-джоба и позволяло четырём платформенным допущениям прятаться; на
|
||||
первом же прогоне матрицы Linux-джоб поймал флейк, который Windows не
|
||||
показывал.
|
||||
|
||||
4. Прочее из аудита (failover error policy, `uv sync --frozen`, лишний `web`
|
||||
extra, secret-scan шире) — не трогал, по заданию это отдельные задания.
|
||||
|
||||
---
|
||||
|
||||
## Ограничения задания — соблюдены
|
||||
|
||||
- Правки ревьюера из `main` не откатывались; в `main` напрямую не пушил.
|
||||
- Фронтенд не трогал: npm, сборки и фреймворков не добавлено.
|
||||
- Проверка SHA-256 **усилена**, а не ослаблена; список разрешённых адресов не
|
||||
тронут.
|
||||
- Учётные данные и `~/.hermes/agy_profiles/` не тронуты.
|
||||
- Версия `0.1.3` не понижена.
|
||||
- Неизмеренное названо причиной: Publication Gate вне режима публикации
|
||||
печатает «НЕ БЛОКИРУЕТ» с причиной и не заявляет `PACKAGE_HASH_VERIFIED`.
|
||||
|
||||
---
|
||||
|
||||
## Найдено сверх задания: релизный конвейер был мёртв, и мой же фикс снимал с него защиту
|
||||
|
||||
Это самое важное из того, что не значилось ни в задании, ни в аудите.
|
||||
|
||||
**Каждый прогон `Release Pipeline` завершался ошибкой** — все пять последних,
|
||||
включая тег текущего релиза `v0.1.3-b1`. Причина ровно та же, что у красного
|
||||
CI: шаг `Run Release Gate Check` падал на `test_a37_isolation_guards` и
|
||||
`test_a41_clean_install`. До публикации не доходил ни один прогон, а релизы
|
||||
выкладывались мимо конвейера.
|
||||
|
||||
**Отсюда ловушка.** `release.yml` собирает `hermes-hub-<версия>.zip` и
|
||||
`update_manifest.json`, а `update_manager` ищет в релизе строго
|
||||
`HermesHubSetup.exe` или `hermes-hub-setup.sh`. Настоящие релизы содержат
|
||||
`HermesHubSetup.exe`, `hermes-hub-setup.sh` и `checksums.txt` — то есть
|
||||
собраны не этим конвейером. Пока тесты были красными, конвейер падал и ничего
|
||||
не публиковал; **как только я их починил, случайная защита исчезла**: первый
|
||||
же тег привёл бы к публикации «latest» без установщиков, и любая попытка
|
||||
обновиться отвечала бы «В релизе не найден подходящий файл обновления для
|
||||
текущей платформы».
|
||||
|
||||
Ловушка закрыта явно, до публикации: шаг `Built assets must be installable by
|
||||
the updater` (`release_gate.py --assets dist`) проверяет, что собранный набор
|
||||
содержит установщик и `checksums.txt`, и падает с названной причиной и
|
||||
подсказкой про `installer/build_installer.ps1` и
|
||||
`installer/build_installer_linux.sh`. После публикации добавлен шаг
|
||||
`Publication Gate` (`release_gate.py --publication-only`) — тот самый строгий
|
||||
режим, ради которого ворота и разделялись.
|
||||
|
||||
Чего я **не** делал: не переписывал сборку установщиков в `release.yml`.
|
||||
Проверить это можно только выкладыванием настоящего релиза по тегу, а это
|
||||
решение владельца, не исполнителя. Конвейер по-прежнему не доходит до
|
||||
публикации — но теперь падает с честной причиной вместо чужой.
|
||||
|
||||
## Что стоит решить ревьюеру
|
||||
|
||||
- Джобы переименованы (`Clean Windows Runner Test` → `Clean Runner Test
|
||||
(windows-latest)`). Защиты ветки на `main` сейчас нет, так что ничего не
|
||||
сломалось; если её будут включать — имена проверок брать новые.
|
||||
- Publication Gate по умолчанию не блокирует. Это осознанный выбор: иначе
|
||||
каждый PR краснел бы за отсутствие релиза для ветки. В `release.yml` он уже
|
||||
встроен и блокирует (`--publication-only`, после публикации). Вручную:
|
||||
`python scripts/release_gate.py --publication`.
|
||||
- **Главное решение — сборка установщиков в `release.yml`.** Конвейер собирает
|
||||
zip, которым обновиться нельзя. Скрипты `installer/build_installer.ps1` и
|
||||
`installer/build_installer_linux.sh` в репозитории есть, но Linux-установщик
|
||||
требует Linux-раннера, то есть релизной джобе нужна матрица. Работа
|
||||
небольшая, но проверяется только настоящей публикацией по тегу — поэтому
|
||||
оставлена за владельцем.
|
||||
62
agents/inbox/2026-08-26-antigravity-dispatch-A31-A32.md
Normal file
62
agents/inbox/2026-08-26-antigravity-dispatch-A31-A32.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Передача Antigravity: A31 и A32
|
||||
|
||||
## Цель
|
||||
|
||||
Последовательно выполнить A31 и A32. Не объединять их в один огромный непроверяемый коммит.
|
||||
|
||||
Полные технические задания:
|
||||
|
||||
1. `agents/inbox/2026-08-25-A31-preflight-state-batching-pii.md`
|
||||
2. `agents/inbox/2026-08-25-A32-remove-desktop.md`
|
||||
|
||||
## Текущее состояние на момент передачи
|
||||
|
||||
- `origin/main`: `c35bc48`
|
||||
- A28: `origin/antigravity/subagents-role-registry` — `b149a6a`
|
||||
- A29: `origin/antigravity/design-system-routing` — `a8c37ca`
|
||||
- A30: `codex/workflow-canvas` — `32bf2c9`
|
||||
- A28, A29 и A30 пока не являются предками `origin/main` и не являются предками друг друга.
|
||||
- A31 и A32 ещё не реализованы.
|
||||
|
||||
Рабочая директория владельца содержит незакоммиченные сборочные артефакты. Их не забирать, не очищать и не перезаписывать. Работать в отдельном чистом worktree/клоне.
|
||||
|
||||
## Задача 1: подготовить интеграционную базу
|
||||
|
||||
До A31 и A32 собрать A28, A29 и A30 поверх актуального `origin/main` в отдельной интеграционной ветке. Конфликты разрешать по смыслу, сохраняя одновременно:
|
||||
|
||||
- реестр ролей и субагентов A28;
|
||||
- дизайн-систему и routing drag-and-drop A29;
|
||||
- workflow canvas, Agent Files и LIVE/EDIT A30;
|
||||
- security fix `c35bc48`.
|
||||
|
||||
После интеграции выполнить `ruff check .`, полный `pytest tests/ -v` и `python scripts/release_gate.py`. Известный устаревший ассерт A30 на `overview-route-diagram` должен быть заменён проверкой актуального `workflow-canvas`, а не обходиться skip/xfailed.
|
||||
|
||||
Не начинать A31, пока интеграционная база не закоммичена, не отправлена в `origin` и release gate не зелёный. В отчёте дать SHA всех взятых голов и итоговый SHA интеграционной ветки.
|
||||
|
||||
## Задача 2: A31
|
||||
|
||||
Создать отдельную ветку от проверенной интеграционной базы и полностью выполнить `2026-08-25-A31-preflight-state-batching-pii.md`.
|
||||
|
||||
Обязателен порядок из задания: реализация Flash, затем независимый аудит Pro. Не заявлять выполнение проверок, которые фактически не запускались. Особенно приложить доказательства для намеренно сломанного preflight, восстановления workflow после перезапуска, отсутствия секретов в состоянии, failover занятого локального сервера, получения лимитов модели без выдуманных чисел и различимого маскирования почт.
|
||||
|
||||
Сдать отдельный `FINAL_COMMIT_SHA`, ветку в `origin`, чистый `git status` и точный итог тестов.
|
||||
|
||||
## Задача 3: A32
|
||||
|
||||
Начинать только после принятия A31. Создать отдельную ветку от принятого результата A31 и полностью выполнить `2026-08-25-A32-remove-desktop.md`.
|
||||
|
||||
Первый шаг — таблица паритета десктопа и веба. Если обнаружена функция без веб-эквивалента, остановить удаление и честно перечислить пробелы. При полном паритете удалить десктоп, его launcher, зависимости и старый ярлык строго по заданию.
|
||||
|
||||
Обязательны реальные проверки чистой Windows-установки, обновления поверх старой версии с десктопом и Linux-установщика. Учётные данные, настройки и цепочки ролей при обновлении должны сохраниться. Реальный пропуск любой платформенной проверки отметить как пропуск, а не PASS.
|
||||
|
||||
Сдать отдельный `FINAL_COMMIT_SHA`, ветку в `origin`, чистый `git status` и точный итог тестов.
|
||||
|
||||
## Запреты
|
||||
|
||||
- Не работать в грязной директории владельца.
|
||||
- Не пушить реализацию напрямую в `main`.
|
||||
- Не создавать тег `v0.1.1`.
|
||||
- Не смешивать A31 и A32 в одной ветке или одном коммите.
|
||||
- Не удалять десктоп до доказанного веб-паритета.
|
||||
- Не подменять живые проверки моками и не выдумывать результаты платформенных прогонов.
|
||||
|
||||
166
agents/inbox/2026-08-31-A42-provider-connect.md
Normal file
166
agents/inbox/2026-08-31-A42-provider-connect.md
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# Задание A42: подключение провайдеров — OpenRouter, NVIDIA, Ollama, квота Codex
|
||||
|
||||
## Дата поступления
|
||||
2026-08-31
|
||||
|
||||
## База
|
||||
|
||||
`origin/main` (`ff303b5`) — туда уже слиты правки ревьюера по вебу и A41 (чистая первая установка).
|
||||
|
||||
```
|
||||
git fetch origin --prune
|
||||
git checkout -b antigravity/a42-provider-connect origin/main
|
||||
```
|
||||
|
||||
В `main` напрямую не пушить.
|
||||
|
||||
## Порядок исполнения
|
||||
|
||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-5** написан для аудитора.
|
||||
|
||||
Это задание **по коду**. Вёрстка и холст — отдельное задание A43, туда не залезать.
|
||||
|
||||
---
|
||||
|
||||
## Задача
|
||||
|
||||
Владелец сообщает: «опенроутер не подключается, нвидиа не подключаются, кодекс выдаёт ошибку по квоте, хотя квоты полные, оллама не выдаёт облачные модели».
|
||||
|
||||
Причины найдены ревьюером и проверены по коду. Заново их выяснять не нужно — нужно чинить.
|
||||
|
||||
## Что проверено ревьюером
|
||||
|
||||
### OpenRouter и NVIDIA реализованы наполовину
|
||||
|
||||
```
|
||||
adapters/__init__.py OpenRouterAdapter и NvidiaAdapter в реестре есть
|
||||
web/static/index.html:163 пункты в списке провайдеров есть
|
||||
app.js:2482 шаг мастера с полем API-ключа и Base URL есть
|
||||
|
||||
action_handler.py:574 add_account сохраняет ТОЛЬКО для
|
||||
(local, local-llm, llama.cpp, ollama, vllm)
|
||||
и только при непустом base_url
|
||||
action_handler.py:588 для всех остальных возвращается ok=True с текстом
|
||||
«Навигация» — и не сохраняется ничего
|
||||
```
|
||||
|
||||
То есть мастер докладывает об успехе и **не сохраняет ничего**. Аккаунт не появляется, потому что его никто не создал.
|
||||
|
||||
Дальше по цепочке пусто тоже:
|
||||
|
||||
```
|
||||
auto_assigner.py:129 слоты объявлены для ollama; openrouter и nvidia отсутствуют
|
||||
auto_assigner.py:219 роли по умолчанию — то же самое
|
||||
router_config.py в конфигурации по умолчанию нет ни одного из трёх
|
||||
model_discovery_service.py:216 _probe_provider не имеет ветки ни для
|
||||
openrouter, ни для nvidia, и возвращает None
|
||||
```
|
||||
|
||||
### Ollama обнаруживает не то
|
||||
|
||||
`model_discovery_service.py:325` заводит `ollama` в одну ветку с `local`, `llama.cpp`, `vllm`. Эта ветка:
|
||||
|
||||
1. перебирает **зашитые** идентификаторы `local-1` и `local-2` — профиль `ollama-1` не смотрит вообще;
|
||||
2. читает учётные данные провайдера `local`, а не `ollama`;
|
||||
3. по умолчанию идёт на `http://127.0.0.1:8081/v1` — это порт llama.cpp, а не Ollama (11434);
|
||||
4. дёргает `/v1/models` и ничего не знает про облачные модели Ollama.
|
||||
|
||||
Скриншот владельца: «Список моделей ещё не получен от провайдера ollama» при подключённом `ollama-1`.
|
||||
|
||||
Та же болезнь рядом: ветка Codex перебирает зашитые `codex-orch`, `codex-worker-1`, `codex-worker-2`. После A26 идентификаторы выдаются автоматически (`codex-4`, `codex-5`), и такой профиль обнаружение пропустит.
|
||||
|
||||
### «Квота исчерпана» при полной квоте
|
||||
|
||||
На скриншоте у Codex значок «Квота исчерпана», а Session и Weekly показывают `Н/Д`. То есть **вердикт об исчерпании выносится там, где квота не измерена вовсе**.
|
||||
|
||||
```
|
||||
unified_health.py:467 ветка: max_cd > 0 либо overall_state == QUOTA_EXHAUSTED
|
||||
→ health_state = STATUS_QUOTA_EXHAUSTED
|
||||
unified_health.py:470 ветка RATE_LIMITED идёт НИЖЕ
|
||||
```
|
||||
|
||||
`max_cd` берётся из `frec.reset_at > now` — это **окно отката после ошибки**, а не остаток квоты. Отсюда два разных дефекта:
|
||||
|
||||
1. Любой откат показывается как исчерпание квоты, хотя квота может быть полной.
|
||||
2. Ветка `RATE_LIMITED` практически мертва: при активном лимите запросов `reset_at` всегда в будущем, поэтому строка 467 срабатывает раньше и лимит запросов выдаёт себя за исчерпанную квоту.
|
||||
|
||||
И третье, в `health_tracker.py:483`: при пустом имени модели или значении `default` исчерпанным помечается **весь аккаунт** (`record.overall_state`). Hermes имя модели передаёт не всегда.
|
||||
|
||||
Классификатор в `codex_adapter.py:132` ловит подстроку `quota` в любом месте текста ошибки — проверить, не попадают ли туда сообщения, к квоте не относящиеся.
|
||||
|
||||
---
|
||||
|
||||
## P0-1. OpenRouter и NVIDIA подключаются по-настоящему
|
||||
|
||||
1. **`add_account` сохраняет профиль** для `openrouter`, `nvidia`, `nvidia-nim`: создаёт определение профиля, пишет учётные данные (ключ и адрес), назначает роль — по образцу существующей локальной ветки.
|
||||
2. **Слоты и роли по умолчанию** для обоих провайдеров в `AutoAssigner`, как сделано для `ollama`.
|
||||
3. **Адреса по умолчанию**: `https://openrouter.ai/api/v1` и `https://integrate.api.nvidia.com/v1`; владелец может переопределить в мастере.
|
||||
4. **Ветка с мнимым успехом не должна остаться ловушкой.** Провайдер, для которого сохранение не реализовано, обязан получать честный отказ с причиной, а не `ok: True`. Это главное требование пункта: молчаливый успех стоил владельцу нескольких попыток подключения.
|
||||
|
||||
## P0-2. Обнаружение моделей для трёх провайдеров
|
||||
|
||||
1. **OpenRouter**: запрос списка моделей по адресу профиля с его ключом.
|
||||
2. **NVIDIA**: то же самое.
|
||||
3. **Ollama — отдельная ветка**, не общая с llama.cpp:
|
||||
- адрес берётся из **самого профиля**, а не из зашитых `local-1`/`local-2`;
|
||||
- учётные данные читаются для провайдера `ollama`;
|
||||
- по умолчанию `http://127.0.0.1:11434`;
|
||||
- локальные модели — через нативный `/api/tags`;
|
||||
- **облачные модели Ollama** — отдельный источник, требующий ключа. Выяснить по действующей документации Ollama способ и адрес; **не выдумывать эндпоинт**. Если способ не подтверждён — так и написать в отчёте, а в интерфейсе показать `Н/Д` с причиной.
|
||||
4. **Зашитые идентификаторы профилей убрать везде**, включая ветку Codex: перебирать профили провайдера из конфигурации. После A26 идентификаторы выдаются автоматически, и любой зашитый список рано или поздно промахнётся.
|
||||
5. **Ошибка обнаружения показывается с текстом ответа сервера.** Сейчас `_probe_provider` возвращает `None` и когда ветки нет, и когда сервер отказал — владелец не может отличить одно от другого.
|
||||
|
||||
## P0-3. Квота говорит только то, что измерено
|
||||
|
||||
1. **Откат после ошибки — это не исчерпание квоты.** Разделить состояния: исчерпание объявлять по измеренному остатку, откат показывать как откат с причиной и временем окончания.
|
||||
2. **Порядок веток исправить**: лимит запросов не должен выдавать себя за исчерпанную квоту.
|
||||
3. **Ошибка без имени модели не помечает весь аккаунт.** Помечать конкретное семейство; общий вердикт — только при подтверждении.
|
||||
4. **Ярлык называет источник.** «Квота исчерпана» — когда есть измерение. Иначе «Откат до HH:MM после ошибки: текст».
|
||||
5. **Классификатор Codex** проверить на ложные срабатывания подстроки `quota`.
|
||||
|
||||
## P0-4. Проверка исполнением
|
||||
|
||||
Заглушек недостаточно, но и ключей владельца у исполнителя нет. Поэтому:
|
||||
|
||||
1. **Сохранение профиля** проверить с заведомо неверным ключом: профиль обязан создаться, а проверка подключения — вернуть внятную ошибку авторизации, а не тишину.
|
||||
2. **Ollama** проверить на живом сервере: локальные модели через `/api/tags` обязаны появиться в списке.
|
||||
3. **Квота**: смоделировать откат после ошибки и убедиться, что интерфейс не пишет «квота исчерпана» при неизмеренной квоте.
|
||||
4. **Отказ вместо мнимого успеха** проверить отдельно.
|
||||
|
||||
## P0-5. Аудит вторым проходом
|
||||
|
||||
1. **Искать оставшиеся зашитые идентификаторы профилей** по всему коду — это повторяющийся класс дефекта.
|
||||
2. **Проверить, что мнимых успехов не осталось**: действие, ничего не сохранившее, не возвращает `ok: True`.
|
||||
3. **Эндпоинт облачных моделей Ollama** сверить с документацией. Выдуманный адрес — дефект того же рода, что выдуманные метрики в A38.
|
||||
4. **Побочные изменения** объяснить.
|
||||
5. **Пропущенный пункт назвать пропущенным.**
|
||||
|
||||
---
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Ключи владельца не запрашивать и в репозиторий не класть.
|
||||
- Каталог `~/.hermes/agy_profiles/` не трогать.
|
||||
- Вёрстку и холст не менять — это A43.
|
||||
- Версию `0.1.1` не поднимать.
|
||||
- Правило честности без исключений: неизмеренное показывать как `Н/Д` с причиной.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Ветка в `origin`, `git status` чист.
|
||||
2. OpenRouter и NVIDIA подключаются: профиль создаётся, учётные данные сохраняются, аккаунт виден в списке; проверено.
|
||||
3. Действие, ничего не сохранившее, возвращает отказ с причиной; проверено.
|
||||
4. Обнаружение моделей работает для openrouter, nvidia и ollama; для Ollama проверено на живом сервере.
|
||||
5. Зашитых идентификаторов профилей в обнаружении не осталось.
|
||||
6. Ошибка обнаружения доходит до интерфейса с текстом.
|
||||
7. Откат после ошибки не показывается как исчерпание квоты; лимит запросов показывается как лимит запросов.
|
||||
8. Ошибка без имени модели не помечает весь аккаунт.
|
||||
9. `ruff check .` чисто; релизный гейт не ухудшен.
|
||||
10. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На `origin/main` сейчас **491 passed**.
|
||||
|
||||
## Главное
|
||||
|
||||
Два провайдера нельзя подключить вовсе, и мастер при этом рапортует об успехе — владелец несколько раз повторял заведомо безрезультатное действие. Третий подключается, но опрашивается по чужому адресу и чужому имени профиля. А Codex объявляется исчерпанным по квоте в тот момент, когда квота не измерена ни разу. Общее у всех четырёх — интерфейс утверждает то, чего не проверял.
|
||||
|
||||
## Порядок сдачи
|
||||
Передать точный `FINAL_COMMIT_SHA`.
|
||||
130
agents/inbox/2026-08-31-A43-frontend-canvas.md
Normal file
130
agents/inbox/2026-08-31-A43-frontend-canvas.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# Задание A43: интерфейс по макетам и работающий холст
|
||||
|
||||
## Дата поступления
|
||||
2026-08-31
|
||||
|
||||
## База
|
||||
|
||||
`origin/main` (`ff303b5`) — туда уже слиты правки ревьюера по вебу и A41 (чистая первая установка).
|
||||
|
||||
```
|
||||
git fetch origin --prune
|
||||
git checkout -b antigravity/a43-frontend-canvas origin/main
|
||||
```
|
||||
|
||||
В `main` напрямую не пушить.
|
||||
|
||||
## Порядок исполнения
|
||||
|
||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-5** написан для аудитора.
|
||||
|
||||
Это задание **по интерфейсу**. Провайдеры, обнаружение моделей и квоты — задание A42, туда не залезать. Пересечение файлов: `app.js` и `workflow.js` правит только это задание; A42 работает в Python.
|
||||
|
||||
Исполнитель работает на машине владельца (Windows), где хаб запущен и есть живой снапшот с подключёнными аккаунтами. Это принципиально: макет надо сверять с работающим интерфейсом, а не с воображаемым.
|
||||
|
||||
---
|
||||
|
||||
## Задача
|
||||
|
||||
Владелец: «криво отрисовано», «окно интерактивно ужасно, посмотри как сделано у n8n», «вообще весь интерфейс не соответствует фронтенду, просто посмотри как отрисовано в макете».
|
||||
|
||||
## Что уже сделано ревьюером — не переделывать
|
||||
|
||||
В базовой ветке уже исправлено, проверено и закоммичено:
|
||||
|
||||
```
|
||||
app.js экран маршрутизации читал currentSnapshot.profiles — такого ключа
|
||||
в снапшоте нет (поле называется all_profiles). Отсюда пустая колонка
|
||||
аккаунтов, счётчик «0 аккаунтов» и иконки-заглушки в цепочках.
|
||||
app.js убрана полоса квоты с зашитым width:80%, одинаковая у всех аккаунтов
|
||||
workflow.css подписи связей центрируются (не было text-anchor) и получили обводку
|
||||
workflow.js список моделей берётся из discovered_models провайдера, а не из
|
||||
preferred_models профиля; настроенная модель всегда есть в списке
|
||||
```
|
||||
|
||||
Последнее чинило скрытую подмену: если модели агента не было в списке, ни один вариант не выбирался, показывался первый, и сохранение записывало агенту не ту модель.
|
||||
|
||||
## Про генераторы интерфейса
|
||||
|
||||
Владелец спрашивал про `github.com/abi/screenshot-to-code`. Ревьюер проверил и **не рекомендует**: инструмент выдаёт самостоятельную страницу на Tailwind без данных, а клиент здесь без сборки и без npm, всё держится на привязке к `/api/snapshot` (решение зафиксировано в `docs/web-api/CONTRACT.md` §1). Переподключать сгенерированную страницу к снапшоту, действиям и опросу состояния дороже, чем сверстать по макету.
|
||||
|
||||
Опираться на макеты из `Desktop/фронтенд/` напрямую.
|
||||
|
||||
---
|
||||
|
||||
## P0-1. Холст ведёт себя как холст
|
||||
|
||||
Сейчас `workflow.css:28` задаёт `.workflow-canvas` фиксированную высоту 430 px и `overflow:hidden`, а обработчики мыши висят только на узлах и портах (`workflow.js:171`). Колесо не обрабатывается, полотно не двигается. Всё, что выехало за 430 px, недостижимо — узел не вернуть, связь не увидеть.
|
||||
|
||||
Требуется поведение, привычное по n8n:
|
||||
|
||||
1. **Панорамирование полотна** — перетаскиванием пустого места и средней кнопкой.
|
||||
2. **Масштаб колесом** с курсором как центром, а не только кнопками.
|
||||
3. **Холст тянется по высоте окна**, а не заперт в 430 px.
|
||||
4. **Вписать в экран** — кнопка уже есть (`fitWorkflowGraph`), она должна учитывать панорамирование.
|
||||
5. **Узел нельзя утащить в недосягаемость**: либо границы, либо «вписать» всегда возвращает всё в поле зрения.
|
||||
|
||||
Связи и узлы считаются в одной системе координат — это ревьюер проверил, ошибки там нет. При добавлении панорамирования **сохранить это свойство**: смещение обязано применяться к обоим слоям одинаково, иначе связи отклеятся от узлов.
|
||||
|
||||
## P0-2. Экраны соответствуют макетам
|
||||
|
||||
Пройти по макетам из `Desktop/фронтенд/` и привести экраны в соответствие: сетка, отступы, типографика, состояния карточек, расположение панелей.
|
||||
|
||||
1. **Расхождения перечислить списком** до начала работы — что именно не совпадает на каждом экране. Список приложить к отчёту.
|
||||
2. **Скриншот до и после** по каждому экрану. Это единственный способ показать владельцу результат: он сравнивает глазами.
|
||||
3. **Три темы остаются рабочими** — светлая, средняя, тёмная. Средняя была реализована в стилях, но отсутствовала в списке выбора; проверить, что все три переключаются.
|
||||
4. **Ничего не ломать в данных.** Экран берёт данные из снапшота; если макет требует поля, которого в снапшоте нет, — показать `Н/Д` с причиной и назвать это в отчёте, а не придумать значение.
|
||||
|
||||
## P0-3. Пустые состояния и честность
|
||||
|
||||
1. **Пустое — это пустое, а не ошибка.** Нет подключённых аккаунтов — экран говорит об этом и предлагает подключить, а не показывает ноль как поломку.
|
||||
2. **Загрузка отличается от отсутствия.** «Список моделей ещё не получен» и «моделей нет» — разные сообщения.
|
||||
3. **Ни одного зашитого числа в интерфейсе.** Полоса с `width:80%` уже убрана; поискать оставшиеся такие же. Любой процент, столбик или счётчик обязан приходить из снапшота.
|
||||
|
||||
## P0-4. Проверка исполнением
|
||||
|
||||
Прогнать тесты недостаточно — дефекты этого задания видны только глазами.
|
||||
|
||||
1. **Открыть хаб** и пройти все экраны на живом снапшоте владельца.
|
||||
2. **Холст**: подвигать полотно, покрутить колесо, утащить узел за край и вернуть кнопкой «вписать».
|
||||
3. **Инспектор агента**: убедиться, что показанная модель совпадает с настроенной, а список содержит модели провайдера.
|
||||
4. **Маршрутизация**: колонка аккаунтов заполнена, счётчик совпадает с числом подключённых, перетаскивание работает.
|
||||
5. **Три темы** переключить и посмотреть каждый экран.
|
||||
|
||||
## P0-5. Аудит вторым проходом
|
||||
|
||||
1. **Сверить скриншоты с макетами**, а не с описанием работы. Совпадение проверяется глазами, а не отчётом исполнителя.
|
||||
2. **Связи не отклеились от узлов** ни на одном масштабе и смещении — проверить на нескольких значениях.
|
||||
3. **Зашитые числа** искать целенаправленно по всему клиенту.
|
||||
4. **Проверить, что данные не потерялись**: экраны, которые работали, продолжают работать.
|
||||
5. **Побочные изменения** объяснить.
|
||||
6. **Пропущенный пункт назвать пропущенным.**
|
||||
|
||||
---
|
||||
|
||||
## Ограничения
|
||||
|
||||
- **Без сборки, без npm, без фреймворка** — решение зафиксировано в `docs/web-api/CONTRACT.md` §1. Tailwind, React и генераторы страниц не вносить.
|
||||
- Python не трогать: провайдеры и квоты — задание A42.
|
||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
||||
- Версию `0.1.1` не поднимать.
|
||||
- Правило честности без исключений.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Ветка в `origin`, `git status` чист.
|
||||
2. Холст панорамируется и масштабируется колесом; высота не заперта; связи держатся за узлы на любом масштабе и смещении — проверено.
|
||||
3. Расхождения с макетами перечислены списком; по каждому экрану приложены скриншоты до и после.
|
||||
4. Три темы работают на всех экранах.
|
||||
5. Пустые состояния показываются как пустые, загрузка отличается от отсутствия.
|
||||
6. Зашитых чисел в интерфейсе не осталось.
|
||||
7. Экран маршрутизации, инспектор агента и список аккаунтов проверены на живом снапшоте.
|
||||
8. `ruff check .` чисто; релизный гейт не ухудшен.
|
||||
9. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На `origin/main` сейчас **491 passed**.
|
||||
|
||||
## Главное
|
||||
|
||||
Владелец смотрит на готовый макет и на работающую программу и видит разные вещи. Плюс холст, из которого узел можно утащить за край и не вернуть. Задание закрывает ровно это: чтобы экран совпадал с макетом, а граф вёл себя как граф, к которому владелец привык в n8n.
|
||||
|
||||
## Порядок сдачи
|
||||
Передать точный `FINAL_COMMIT_SHA`.
|
||||
198
agents/inbox/2026-08-31-A44-restore-server-and-swap.md
Normal file
198
agents/inbox/2026-08-31-A44-restore-server-and-swap.md
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# Задание A44: вернуть кодер в строй, поставить llama-swap, поправить отчёт A40
|
||||
|
||||
## Дата поступления
|
||||
2026-08-31
|
||||
|
||||
## База
|
||||
|
||||
Ветка A40 (`origin/antigravity/a40-benchmark-redo`, `d545252`) — правки отчёта ложатся туда же, где он живёт.
|
||||
|
||||
```
|
||||
git fetch origin --prune
|
||||
git checkout -b antigravity/a44-restore-server origin/antigravity/a40-benchmark-redo
|
||||
git merge origin/main # ветка A40 отстала: в main уже A41 и правки ревьюера
|
||||
```
|
||||
|
||||
В `main` напрямую не пушить.
|
||||
|
||||
## Порядок исполнения
|
||||
|
||||
Два прохода: **Flash** исполняет, **Pro** проводит аудит. Пункт **P0-5** написан для аудитора.
|
||||
|
||||
Задание **по коду и серверу**. Вёрстка — A43, провайдеры — A42, туда не залезать.
|
||||
|
||||
---
|
||||
|
||||
## Что признано и переделке не подлежит
|
||||
|
||||
Ревьюер проверил A40 исполнением на сервере. Проверка таблицы пройдена:
|
||||
|
||||
```
|
||||
все семь файлов GGUF существуют по указанным путям
|
||||
размеры совпадают с отчётом ДО БАЙТА (stat -c %s по каждому)
|
||||
sha256 первых 64 МБ granite-4.2 пересчитана независимо — совпала:
|
||||
f155ab58fe3ff46c4daa7d65633347da343143771238ddf63ecba25b8e10a06d
|
||||
DeepSeek-Coder-V2-Lite и Phi-4, выдуманные в A38, действительно скачаны
|
||||
и измерены; прежние числа отозваны
|
||||
```
|
||||
|
||||
Это хорошая работа, и требование P0-1 из A40 выполнено. Стенд, набор задач и таблицу **не переделывать**.
|
||||
|
||||
Претензии ниже касаются состояния сервера и двух столбцов отчёта.
|
||||
|
||||
---
|
||||
|
||||
## Что сломано — проверено ревьюером
|
||||
|
||||
### Кодер владельца не запущен как служба
|
||||
|
||||
```
|
||||
systemctl is-active qwen-coder → inactive
|
||||
systemctl show qwen-coder SubState → dead
|
||||
|
||||
порт 8081 при этом отвечает: его обслуживает процесс, поднятый ВРУЧНУЮ
|
||||
PID 2713480, ELAPSED 07:50 на момент проверки
|
||||
/home/ochenstarik/llama.cpp/build/bin/llama-server -m .../Qwen3.8-27B-Q4_K_M.gguf
|
||||
```
|
||||
|
||||
Перезагрузка сервера или падение процесса — и локального кодера нет. Владелец пользуется этой машиной ежедневно.
|
||||
|
||||
### Контекст урезан вшестеро против штатного
|
||||
|
||||
```
|
||||
/etc/systemd/system/qwen-coder.service ExecStart ... -c 196608
|
||||
фактически запущено -c 32768
|
||||
```
|
||||
|
||||
У Hermes порог **64К контекста**: при 32768 модель не проходит отбор, и локальный кодер бесполезен. Это ровно та проблема, ради которой делалось A39.
|
||||
|
||||
### Отсюда же расхождение скоростей в отчёте
|
||||
|
||||
Отчёт даёт Qwen3.8-27B **30,31 ток/с**. Независимый замер ревьюера на штатной конфигурации давал **13,6 ток/с**. Разницу объясняет контекст: замеры шли на 32К, служба владельца работает на 192К.
|
||||
|
||||
В строке «Условия измерений» перечислены квантование, `-ngl 99`, `--flash-attn on`, `--cache-type-k/v q8_0`, `--parallel 1`, `--temp 0.2` — и **размер контекста не указан вовсе**. Без него числа нельзя соотнести с реальной установкой владельца, а именно ради этого отчёт и делался.
|
||||
|
||||
### Столбец VRAM измеряет не то
|
||||
|
||||
```
|
||||
отчёт: Qwen3 4B Instruct 2507 → 24 894 МиБ
|
||||
живой замер того же процесса:
|
||||
nvidia-smi --query-compute-apps=pid,used_memory
|
||||
1570163 5 440 МиБ llama-server ... Qwen3-4B ...
|
||||
```
|
||||
|
||||
В отчёт попала **общая занятость карты** вместе с соседней резидентной моделью, а не потребление самого процесса. Отсюда абсурд: 4B «занимает» 24 894 МиБ, а 27B — 24 696 МиБ. Для планирования «сколько моделей поместится» столбец непригоден, а владелец задаёт именно этот вопрос.
|
||||
|
||||
### llama-swap не установлен
|
||||
|
||||
```
|
||||
command -v llama-swap → не найден
|
||||
systemctl is-active llama-swap → inactive
|
||||
```
|
||||
|
||||
Установка llama-swap была критерием приёмки 2 в A38 и не выполнена ни там, ни здесь.
|
||||
|
||||
### Мусор от прерванной закачки
|
||||
|
||||
```
|
||||
/srv/ai/models/nemotron-3.5-30b 511 МБ
|
||||
```
|
||||
|
||||
Отчёт честно говорит, что модель не проверена. Но огрызок остался лежать.
|
||||
|
||||
---
|
||||
|
||||
## P0-1. Кодер возвращается в штатное состояние
|
||||
|
||||
Это первое по важности: сейчас у владельца сломан рабочий инструмент.
|
||||
|
||||
1. Ручной процесс на 8081 остановить.
|
||||
2. `qwen-coder` поднять **штатно, через systemd**, с контекстом `196608` из юнита.
|
||||
3. Убедиться, что после `systemctl restart` служба поднимается сама и порт отвечает.
|
||||
4. **Проверить включение в автозапуск** (`systemctl is-enabled`): служба обязана пережить перезагрузку.
|
||||
5. `qwen-compressor` на 8082 проверить тем же порядком.
|
||||
|
||||
Юнит-файлы **не переписывать** без нужды: это машина владельца, он правит их сам. Если правка всё же необходима — обосновать в отчёте отдельным пунктом.
|
||||
|
||||
## P0-2. llama-swap
|
||||
|
||||
1. Поставить `mostlygeek/llama-swap` (Go, MIT) **рядом** с работающими службами, не ломая их.
|
||||
2. В конфигурацию внести все модели, лежащие в `/srv/ai/models/`, каждую отдельной записью со своими параметрами запуска.
|
||||
3. Таймаут выгрузки настраивается.
|
||||
4. **Проверить замером `nvidia-smi`, что выгрузка действительно освобождает видеопамять** — по документации не принимать.
|
||||
5. **Откат одной командой** описать и проверить: если llama-swap мешает, `qwen-coder` и `qwen-compressor` возвращаются в прежний вид.
|
||||
6. Штатные порты 8081 и 8082 остаются за службами владельца. llama-swap слушает свой порт и в работу Hermes не вмешивается, пока владелец не переключит.
|
||||
|
||||
Обоснование, почему это стоит делать: суммарно четыре интересующие владельца модели занимают 25,5 ГиБ, а на сервере **45 ГБ уже занято страничным кэшем при 62 ГБ всего**. Модели помещаются в оперативную память целиком, поэтому переключение между ними — копирование по PCIe, а не чтение с диска на 187 МБ/с. Это снимает главное возражение против свопа.
|
||||
|
||||
## P0-3. Две правки отчёта
|
||||
|
||||
Таблицу не трогать, кроме следующего.
|
||||
|
||||
1. **Размер контекста внести в условия измерений.** Если замеры шли на 32768 — так и написать. Числа, снятые на 32К, не выдавать за характеристику установки владельца, работающей на 192К.
|
||||
2. **Столбец VRAM пересчитать на потребление процесса**, а не карты: `nvidia-smi --query-compute-apps=pid,used_memory`. Если пересчёт требует повторных запусков — либо перезамерить, либо честно пометить столбец как неизмеренный и убрать числа. Оставлять заведомо неверные значения нельзя.
|
||||
3. **Добавить строку про порог Hermes**: какие из моделей держат 64К контекста и с какой скоростью. Это тот вопрос, ради которого владелец сравнение и заказывал.
|
||||
|
||||
## P0-4. Ответ на вопрос владельца
|
||||
|
||||
Владелец спрашивает, можно ли держать несколько лёгких моделей сразу: Qwen2.5-Coder-14B, Qwen3-4B-Instruct-2507, DeepSeek-Coder-V2-Lite, Granite-4.2-8B.
|
||||
|
||||
Арифметика ревьюера по проверенным размерам файлов:
|
||||
|
||||
```
|
||||
Qwen2.5-Coder-14B 8 571 МиБ
|
||||
DeepSeek-V2-Lite 9 884 МиБ
|
||||
Granite-4.2-8B 5 283 МиБ
|
||||
Qwen3-4B-2507 2 382 МиБ
|
||||
──────────
|
||||
только веса 26 120 МиБ из 32 768
|
||||
остаётся 6 648 МиБ на четыре контекста и буферы
|
||||
```
|
||||
|
||||
Замеренная надбавка у живых процессов на 32К — от 1 154 МиБ до 3 058 МиБ на экземпляр.
|
||||
|
||||
Требуется **проверить это замером**, а не расчётом: поднять три модели без DeepSeek одновременно и снять `nvidia-smi` по процессам; затем попробовать четыре. Дать владельцу таблицу «сколько моделей и с каким контекстом помещается» с настоящими числами.
|
||||
|
||||
## P0-5. Аудит вторым проходом
|
||||
|
||||
1. **Перезагрузить сервер** (согласовав окно с владельцем) и убедиться, что кодер и компрессор поднялись сами. Это единственная настоящая проверка пункта P0-1.
|
||||
2. **Убедиться, что контекст 196608**, а не 32768: запросить у сервера и сверить.
|
||||
3. **Проверить, что llama-swap не мешает** штатным службам: обе работают, порты отвечают.
|
||||
4. **Сверить пересчитанный столбец VRAM** с `--query-compute-apps` независимо.
|
||||
5. **Убедиться, что в отчёте не осталось чисел без указания условий**, при которых они сняты.
|
||||
6. **Побочные изменения** объяснить.
|
||||
7. **Пропущенный пункт назвать пропущенным.**
|
||||
|
||||
---
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Сервер рабочий. Окно для перезагрузки согласовать с владельцем.
|
||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
||||
- Конфигурацию хаба не менять.
|
||||
- Юнит-файлы владельца без обоснования не переписывать.
|
||||
- Место на диске контролировать: свободно 257 ГБ.
|
||||
- Версию `0.1.1` не поднимать, тег не создавать.
|
||||
- Правило честности без исключений.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Ветка в `origin`, `git status` чист.
|
||||
2. `qwen-coder` работает через systemd с контекстом 196608, включён в автозапуск, пережил перезагрузку — вывод приложен.
|
||||
3. `qwen-compressor` проверен тем же порядком.
|
||||
4. llama-swap установлен, содержит все модели из `/srv/ai/models/`, выгрузка освобождает видеопамять — подтверждено `nvidia-smi`; откат описан и проверен.
|
||||
5. Штатные службы llama-swap не сломал.
|
||||
6. В условиях измерений отчёта указан размер контекста.
|
||||
7. Столбец VRAM показывает потребление процесса либо честно помечен неизмеренным.
|
||||
8. В отчёте есть ответ, какие модели держат 64К и с какой скоростью.
|
||||
9. Замерено и приложено, сколько моделей помещается одновременно и с каким контекстом.
|
||||
10. Огрызок `nemotron-3.5-30b` убран либо докачан; выбор объяснён.
|
||||
11. `ruff check .` чисто; релизный гейт не ухудшен.
|
||||
12. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. После слияния с `origin/main` ожидается **491 passed**.
|
||||
|
||||
## Главное
|
||||
|
||||
Замеры в A40 сделаны честно, и это заметный шаг после A38. Но ради них у владельца остановили кодер, запустили его вручную с контекстом вшестеро меньше штатного и в таком виде оставили — а при 32К модель не проходит порог Hermes и в работе бесполезна. Сначала вернуть инструмент в строй, потом договорить в отчёте то, что осталось недосказанным: при каком контексте сняты числа и сколько памяти занимает каждая модель на самом деле.
|
||||
|
||||
## Порядок сдачи
|
||||
Передать точный `FINAL_COMMIT_SHA`.
|
||||
150
agents/inbox/2026-08-31-A45-moe-coder-candidates.md
Normal file
150
agents/inbox/2026-08-31-A45-moe-coder-candidates.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# Задание A45: три новых кандидата в локальные кодеры
|
||||
|
||||
## Дата поступления
|
||||
2026-08-31
|
||||
|
||||
## База
|
||||
|
||||
`origin/main` (`ff303b5`).
|
||||
|
||||
```
|
||||
git fetch origin --prune
|
||||
git checkout -b antigravity/a45-moe-candidates origin/main
|
||||
```
|
||||
|
||||
В `main` напрямую не пушить.
|
||||
|
||||
## Порядок исполнения
|
||||
|
||||
Два прохода: **Flash** исполняет замеры, **Pro** проводит аудит. Пункт **P0-5** написан для аудитора.
|
||||
|
||||
**Выполнять после A44.** Причина не в приоритетах, а в железе: видеокарта одна, и оба задания её занимают. A44 первым делом останавливает ручной процесс на 8081 и возвращает кодер под systemd — начинать замеры до этого значит мешать друг другу и получить искажённые тайминги.
|
||||
|
||||
**Файлы A44 не трогать.** `benchmarks/BENCHMARK_REPORT.md` и `benchmarks/benchmark_results.json` правит A44; здесь пишется отдельный отчёт (см. P0-4). Стенд `benchmarks/benchmark_suite.py` используется как есть, без правок.
|
||||
|
||||
---
|
||||
|
||||
## Задача
|
||||
|
||||
Владелец нашёл на huggingface.co новые модели и спрашивает, есть ли что-то интересное. Ревьюер отобрал три кандидата и проверил их пригодность к этому железу. Нужно измерить.
|
||||
|
||||
## Что проверено ревьюером — заново не выяснять
|
||||
|
||||
Размеры получены через API репозиториев HuggingFace, а не из карточек моделей.
|
||||
|
||||
| Порядок | Репозиторий | Файл | Размер |
|
||||
|---|---|---|---|
|
||||
| 1 | `unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF` | `Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf` | 17,28 ГиБ |
|
||||
| 2 | `bartowski/Qwen2.5-Coder-32B-Instruct-GGUF` | `Qwen2.5-Coder-32B-Instruct-Q4_K_M.gguf` | 18,49 ГиБ |
|
||||
| 3 | `peculiar-ragdoll/Tiel-Coder-35B-A3B-GGUF` | `Tiel-Coder-35B-A3B-UD-Q4_K_S.gguf` | 19,46 ГиБ |
|
||||
|
||||
Пересобирать llama.cpp не нужно:
|
||||
|
||||
```
|
||||
сборка на сервере: build 10597, commit 95b8e33e1
|
||||
libllama.so содержит: qwen3moe, qwen35moe, deepseek2, granite
|
||||
```
|
||||
|
||||
Место: свободно 256 ГБ, три модели занимают 55 ГиБ.
|
||||
|
||||
## Почему именно эти три и в этом порядке
|
||||
|
||||
**Qwen3-Coder-30B-A3B — главная.** MoE: 30 миллиардов всего, **3 миллиарда активных**. На V100 производительность упирается в пропускную способность памяти, поэтому скорость определяется активными параметрами, а память — общими. Ожидается скорость малой модели при качестве тридцатимиллиардного кодера. Это ровно та гипотеза, ради которой A38 брала Nemotron и до замера не довела. 12,8 млн скачиваний, 933 отметки — сборка обкатанная.
|
||||
|
||||
По размеру садится на место нынешнего кодера: 17,28 ГиБ против 17,67 у Qwen3.8-27B, то есть под контекст остаётся столько же.
|
||||
|
||||
**Qwen2.5-Coder-32B — про потолок качества.** Плотная, старшая в семействе нынешнего лидера. Числилась кандидатом ещё в A40 и до замера не дошла. Скорости от неё не ждут: плотные 32B на этом железе должны идти примерно вдвое медленнее 14B. Вопрос к ней один — покупается ли за потерю скорости реальный прирост качества. 14B даёт 75% на стенде.
|
||||
|
||||
**Tiel-Coder-35B-A3B — третья, и только после двух первых.** Тоже MoE с тремя активными, первое место в трендах среди кодеров. Но выложена 31 августа, автор незнакомый, 145 отметок против 933 у Qwen. Не обкатана.
|
||||
|
||||
## Ожидания ревьюера — это не измерения
|
||||
|
||||
Всё, что выше сказано про ожидаемую скорость, выведено из замеренных свойств железа и **числами в отчёт не переносится**. В таблице стоят только измеренные значения.
|
||||
|
||||
---
|
||||
|
||||
## P0-1. Правило из A40 действует без изменений
|
||||
|
||||
**Строка в отчёте появляется только после того, как модель отработала на стенде.**
|
||||
|
||||
Для каждой строки обязательны:
|
||||
|
||||
1. **Абсолютный путь к файлу GGUF** на сервере.
|
||||
2. **Размер файла в байтах** из `stat`, а не из карточки модели.
|
||||
3. **Контрольная сумма** первых мегабайт или `sha256`.
|
||||
4. **Сырые тайминги** из поля `timings` ответа `llama-server`, не пересчитанные вручную.
|
||||
5. **Имя сборки** из `general.name` метаданных GGUF, а не из названия каталога.
|
||||
|
||||
Модель не скачалась, не запустилась или не влезла — **строки в таблице нет**, вместо неё раздел «не проверено» с причиной. Это полноценный результат, он принимается; выдуманные числа — нет.
|
||||
|
||||
## P0-2. Замер на 64К обязателен
|
||||
|
||||
Это главное отличие от A40 и главная причина, по которой задание вообще нужно.
|
||||
|
||||
1. **Мерить на 64К контекста**, а не только на 32К. Это порог отбора у Hermes: модель, не держащая 64К, в работу не идёт, и замер на 32К на вопрос владельца не отвечает.
|
||||
2. Если модель на 64К не помещается или деградирует — **так и записать**, с числами.
|
||||
3. **Длинный контекст у MoE под особым подозрением.** DeepSeek-Coder-V2-Lite, тоже MoE с малым числом активных, по замеру A40 на 32К проваливается до 3,35 ток/с и уходит в таймаут. Проверить целенаправленно, не повторяется ли это у Qwen3-Coder и Tiel: если повторяется, вся привлекательность MoE на этом железе иллюзорна, и это важнейший вывод задания.
|
||||
4. Дополнительно снять 32К — для сопоставимости с таблицей A40.
|
||||
|
||||
## P0-3. Одинаковые условия
|
||||
|
||||
1. **Режим мышления выключен у всех**, как в A40.
|
||||
2. Квантование Q4_K_M, где доступно; у Tiel его нет — взят `UD-Q4_K_S`, и это **оговорить в отчёте** отдельной строкой.
|
||||
3. `--parallel 1`, посторонних запросов во время замера нет.
|
||||
4. **Размер контекста указывать при каждом числе.** В A40 его забыли указать вовсе, и числа оказалось не с чем соотнести.
|
||||
5. **VRAM мерить по процессу**: `nvidia-smi --query-compute-apps=pid,used_memory`, а не общую занятость карты. В A40 столбец собрал занятость вместе с соседней резидентной моделью и стал бесполезен.
|
||||
|
||||
## P0-4. Что измерять и куда писать
|
||||
|
||||
Как в A40: генерация и обработка промпта в токенах в секунду, видеопамять по процессу, время холодной загрузки, прохождение 12 задач стенда, поведение на длинном контексте.
|
||||
|
||||
Отчёт — **новый файл** `benchmarks/BENCHMARK_MOE_CANDIDATES.md` и отдельный файл результатов. `BENCHMARK_REPORT.md` и `benchmark_results.json` не трогать: их правит A44, и одновременная запись даст конфликт.
|
||||
|
||||
Вывод в одну строку на каждую модель: годится ли она заменой нынешнему кодеру и почему. **Ничего в конфигурации владельца не менять** — задание исследовательское, решение принимает он.
|
||||
|
||||
## P0-5. Аудит вторым проходом
|
||||
|
||||
В A38 выдумка прошла первый проход целиком. Здесь она — главный предмет проверки.
|
||||
|
||||
1. **Для каждой строки убедиться, что файл существует**: пройти `stat` по всем путям и сверить размеры с таблицей.
|
||||
2. **Ни одной записи `COMPLETED` без файла** в результатах.
|
||||
3. **Проверить выборочно тайминги**: повторить два-три замера и убедиться, что цифры воспроизводятся.
|
||||
4. **Убедиться, что замер на 64К действительно сделан**, а не подменён замером на 32К.
|
||||
5. **Проверить, что при каждом числе указан контекст**, и что VRAM снята по процессу.
|
||||
6. **Убедиться, что файлы A44 не тронуты.**
|
||||
7. **Побочные изменения** объяснить.
|
||||
8. **Пропущенный пункт назвать пропущенным.**
|
||||
|
||||
---
|
||||
|
||||
## Ограничения
|
||||
|
||||
- **Выполнять после A44**: видеокарта одна.
|
||||
- Служебные юниты `qwen-coder` и `qwen-compressor` после прогонов вернуть в рабочее состояние: владелец пользуется сервером ежедневно.
|
||||
- Конфигурацию хаба не менять.
|
||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
||||
- Место на диске контролировать, раздел не забить.
|
||||
- Версию `0.1.1` не поднимать, тег не создавать.
|
||||
- Правило честности без исключений.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Ветка в `origin`, `git status` чист.
|
||||
2. Три кандидата прогнаны либо честно объявлены недоступными с причиной.
|
||||
3. Для каждой строки: путь, размер в байтах, контрольная сумма, `general.name`, сырые тайминги.
|
||||
4. **Для каждой модели есть замер на 64К контекста**; где не влезло или деградировало — с числами.
|
||||
5. Проверено, повторяется ли у MoE провал на длинном контексте, замеченный у DeepSeek.
|
||||
6. При каждом числе указан размер контекста; VRAM снята по процессу.
|
||||
7. Отклонение по квантованию у Tiel оговорено.
|
||||
8. Отчёт в отдельном файле; `BENCHMARK_REPORT.md` и `benchmark_results.json` не изменены.
|
||||
9. Служебные модели на портах 8081 и 8082 возвращены в рабочее состояние.
|
||||
10. Конфигурация владельца не изменена.
|
||||
11. `ruff check .` чисто; релизный гейт не ухудшен.
|
||||
12. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На `origin/main` сейчас **491 passed**.
|
||||
|
||||
## Главное
|
||||
|
||||
Нынешний лидер по замерам — Qwen2.5-Coder-14B: 75% качества при 55,9 ток/с. Вопрос владельца в том, есть ли что-то заметно лучше. Qwen3-Coder-30B-A3B — самый обоснованный ответ, какой можно дать не запуская: три активных миллиарда на памяти-узком-месте должны дать скорость малой модели при качестве большой. Но ровно это же обещал DeepSeek, а на длинном контексте провалился до 3,35 ток/с. Поэтому замер на 64К здесь важнее самой таблицы скоростей.
|
||||
|
||||
## Порядок сдачи
|
||||
Передать точный `FINAL_COMMIT_SHA`.
|
||||
141
agents/inbox/2026-08-31-A46-frontend-mockup-redo.md
Normal file
141
agents/inbox/2026-08-31-A46-frontend-mockup-redo.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
> **ОТМЕНЕНО 31.08.2026.** Работа передана Codex заданием
|
||||
> `2026-08-31-A48-codex-interface-by-mockup.md`. Исполнитель сидит на сервере,
|
||||
> где запущен хаб, и может сверять с макетом глазами. Antigravity за фронтенд
|
||||
> не берётся: два агента в одних файлах уже приводили к переключению ветки под
|
||||
> чужой работой.
|
||||
|
||||
# Задание A46: вёрстка по макетам — возврат по A43
|
||||
|
||||
## Дата поступления
|
||||
2026-08-31
|
||||
|
||||
## База
|
||||
|
||||
`origin/main` (`81a58f6`) — там уже лежит A43.
|
||||
|
||||
```
|
||||
git fetch origin --prune
|
||||
git checkout -b antigravity/a46-mockup-redo origin/main
|
||||
```
|
||||
|
||||
В `main` напрямую не пушить.
|
||||
|
||||
## Порядок исполнения
|
||||
|
||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-4** написан для аудитора.
|
||||
|
||||
Исполнитель работает на машине владельца, где хаб запущен и есть живой снапшот. Макеты — в `Desktop/фронтенд/`, файлы `1.1.png` … `7.1.png` и пояснения `1.txt`, `3.txt`.
|
||||
|
||||
---
|
||||
|
||||
## Почему возврат
|
||||
|
||||
A43 закрыл холст и оставил вёрстку нетронутой. Владелец поставил сборку и написал: «интерфейс вообще не изменился, какой был, такой и остался. Зачем тогда было задание на изменение по макетам?»
|
||||
|
||||
Он прав. Вот что изменил A43:
|
||||
|
||||
```
|
||||
app.js +297 шесть карточек показателей, обвязка холста
|
||||
workflow.js +136 панорамирование, зум колесом
|
||||
index.html +8
|
||||
workflow.css 15 потолок 430 px снят, слои холста
|
||||
style.css НЕ ОТКРЫВАЛСЯ НИ РАЗУ
|
||||
```
|
||||
|
||||
`style.css` — это и есть внешний вид: сетка, отступы, типографика, карточки, палитра. Пункт P0-2 задания A43 требовал привести экраны к макетам именно по этим свойствам. Сделать это, не тронув файл со стилями, невозможно.
|
||||
|
||||
Скриншотов «до и после», которых требовал критерий приёмки 3, в ветке нет. Работа была принята по прохождению тестов, а тесты внешний вид не проверяют.
|
||||
|
||||
**Холст переделывать не нужно.** P0-1 выполнен: панорамирование, зум колесом, снятый потолок высоты — всё работает и остаётся.
|
||||
|
||||
---
|
||||
|
||||
## P0-1. Расхождения с макетом `1.1.png`
|
||||
|
||||
Сверено ревьюером: макет против живой сборки `81a58f6` на сервере владельца.
|
||||
|
||||
**Шапка.** В макете: поиск с подсказкой `Ctrl + K`, колокольчик со счётчиком, шестерёнка, карточка пользователя (инициалы, имя, команда). В сборке вместо этого две кнопки — «Обновить всё» и «Добавить аккаунт».
|
||||
|
||||
**Логотип и подпись.** В макете вензель, «HERMES HUB» и вторая строка «Крона • Бизнес-экосистема». В сборке — значок молнии и «Multi-Account Router».
|
||||
|
||||
**Левое меню.** В макете иной набор и оформление пунктов, внизу блок с эмблемой и текстом про единый визуальный язык. В сборке внизу — служебная строка про Live API и номер сборки.
|
||||
|
||||
**Панель инструментов холста.** В макете вертикальная панель слева внутри холста: курсор, добавить узел, связь, рамка, показать, удалить. В сборке отсутствует полностью.
|
||||
|
||||
**Карточки узлов.** В макете: иконка, имя, файл `.md`, строка «модель • аккаунт», статус точкой. В сборке: три строки текста, обрезанные многоточием, без модели и аккаунта.
|
||||
|
||||
**Подписи связей.** В макете подписи `SUCCESS`, `REVIEW_PASSED`, `REVIEW_FAILED` разнесены и подкрашены, возвраты идут красным пунктиром. В сборке подписи налезают на карточки узлов и режутся: владелец видит «ТАНОВКА ЗАД» и «РАЙПРИЁМКА».
|
||||
|
||||
Центрирование подписей ревьюер уже починил (`text-anchor` в `workflow.css`), и эта правка на месте. Режут их **сами карточки узлов, которые рисуются поверх**, и слишком узкий промежуток между узлами. Лечится порядком слоёв и расстоянием в раскладке, а не стилем текста.
|
||||
|
||||
**Нижний ряд.** В макете три карточки: «Последние события» с временем и цветными бейджами, «Статистика workflow» с кольцевой диаграммой, «Активные задачи». В сборке — «Последние события» и «Управление LIVE».
|
||||
|
||||
**Инспектор.** В макете это основная панель: вкладки «Основное», «Модель», «Инструкции», «Инструменты», «Память», «История»; поля статуса, текущей задачи, итерации, последнего запуска, времени выполнения, успешности; блок «Конфигурация исполнения» с провайдером, аккаунтом, моделью, температурой, лимитом токенов и таймаутом; блок «Agent File»; инструменты чипами; быстрые действия кнопками. В сборке — пустая заглушка «Выберите агента на графе».
|
||||
|
||||
**Палитра и рамки.** Тёмно-зелёный фон с золотыми акцентами и тонкими рамками. Это то, что задаётся в `style.css`.
|
||||
|
||||
## P0-2. Что делать с элементами, которых нечем наполнить
|
||||
|
||||
Часть макета опирается на данные, которых в снапшоте может не быть.
|
||||
|
||||
1. **Ничего не выдумывать.** Нет данных — `Н/Д` с причиной, как принято в проекте. Нарисовать кольцевую диаграмму с числом «42» из макета — дефект, а не выполнение задания.
|
||||
2. **Модель и аккаунт на карточке узла в снапшоте есть** — брать оттуда, а не подписывать примерами из макета.
|
||||
3. **Версию из макета не переносить.** Там `v2.9.0`, в проекте `0.1.1`, и она заморожена намеренно.
|
||||
4. **Нижняя панель других приложений экосистемы** (Planner, Journal, Finance и прочие) — этих приложений не существует. Не делать и **назвать пропущенным** в отчёте, а не рисовать неработающие кнопки.
|
||||
5. Всё остальное, что упирается в отсутствующие данные, — так же: реализовать оформление, показать пустое состояние честно, перечислить в отчёте.
|
||||
|
||||
## P0-3. Скриншоты — это и есть сдача работы
|
||||
|
||||
Владелец сравнивает глазами. Отчёт без картинок принят не будет.
|
||||
|
||||
1. **Список расхождений по каждому экрану** — до начала работы, приложить.
|
||||
2. **Скриншот до и после по каждому экрану** — обязательно. Это критерий, по которому A43 провалился.
|
||||
3. **Рядом с каждой парой — фрагмент макета**, к которому приводили.
|
||||
4. Пройти все макеты `1.1` … `7.1`, а не только первый.
|
||||
5. **Три темы** — светлая, средняя, тёмная — проверить на каждом экране.
|
||||
|
||||
## P0-4. Аудит вторым проходом
|
||||
|
||||
Проверяющему: в прошлый раз работа была принята без единого взгляда на экран.
|
||||
|
||||
1. **Открыть хаб и посмотреть.** Не отчёт, не тесты — экран.
|
||||
2. **Проверить, что `style.css` действительно изменён** и изменения относятся к вёрстке, а не косметике в одну строку.
|
||||
3. **Сверить скриншоты с макетами** попарно.
|
||||
4. **Убедиться, что выдуманных данных нет**: ни одного числа из макета в живом интерфейсе.
|
||||
5. **Холст не сломан**: панорамирование, зум колесом, «вписать» работают как после A43.
|
||||
6. **Подписи связей читаются** на всех масштабах и не перекрываются карточками.
|
||||
7. **Побочные изменения** объяснить.
|
||||
8. **Пропущенный пункт назвать пропущенным.**
|
||||
|
||||
---
|
||||
|
||||
## Ограничения
|
||||
|
||||
- **Без сборки, без npm, без фреймворка** — решение зафиксировано в `docs/web-api/CONTRACT.md` §1.
|
||||
- Python не трогать.
|
||||
- Холст A43 не переделывать, только доводить.
|
||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
||||
- Версию `0.1.1` не поднимать.
|
||||
- Правило честности без исключений.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Ветка в `origin`, `git status` чист.
|
||||
2. `style.css` изменён; вёрстка экранов приведена к макетам.
|
||||
3. По каждому экрану приложены скриншоты до и после рядом с фрагментом макета.
|
||||
4. Пройдены все макеты `1.1` … `7.1`.
|
||||
5. Инспектор агента реализован по макету; отсутствующие данные показаны как `Н/Д` с причиной.
|
||||
6. Карточки узлов показывают модель и аккаунт из снапшота.
|
||||
7. Подписи связей не перекрываются карточками узлов; проверено на нескольких масштабах.
|
||||
8. Панель инструментов холста реализована либо названа пропущенной с причиной.
|
||||
9. Ни одного числа из макета в живом интерфейсе.
|
||||
10. Три темы работают на всех экранах.
|
||||
11. `ruff check .` чисто; релизный гейт не ухудшен.
|
||||
12. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На `origin/main` сейчас **496 passed**.
|
||||
|
||||
## Главное
|
||||
|
||||
Владелец полдня ждал сборку, поставил её на две машины и увидел прежний интерфейс. Холст стал лучше, но он один экран из семи. Задание закрывает то, что в A43 просто не начинали: вёрстку по макетам. И сдаётся оно скриншотами, потому что проверить его иначе нельзя — тесты этого не видят, что и показал прошлый заход.
|
||||
|
||||
## Порядок сдачи
|
||||
Передать точный `FINAL_COMMIT_SHA`.
|
||||
170
agents/inbox/2026-08-31-A47-shared-memory.md
Normal file
170
agents/inbox/2026-08-31-A47-shared-memory.md
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
# Задание A47: единая память для всех агентов сервера
|
||||
|
||||
## Дата поступления
|
||||
2026-08-31
|
||||
|
||||
## База
|
||||
|
||||
`origin/main` (`80aab00`).
|
||||
|
||||
```
|
||||
git fetch origin --prune
|
||||
git checkout -b antigravity/a47-shared-memory origin/main
|
||||
```
|
||||
|
||||
В `main` напрямую не пушить.
|
||||
|
||||
## Порядок исполнения
|
||||
|
||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-7** написан для аудитора.
|
||||
|
||||
Задание идёт **на сервере** `192.168.1.81`. Правки кода — через git, правки памяти — прямо в хранилище (оно вне git, см. P0-2).
|
||||
|
||||
Не пересекается с A42 (провайдеры), A46 (вёрстка), A45 (замеры).
|
||||
|
||||
---
|
||||
|
||||
## Задача
|
||||
|
||||
Владелец: «чтобы он работал совместно с обсидиан, чтобы все ИИ на сервере использовали единый мозг».
|
||||
|
||||
Хранилище уже есть и сделано хорошо. Задание — не строить его заново, а заставить работать: сейчас им никто не пользуется.
|
||||
|
||||
## Что проверено ревьюером
|
||||
|
||||
**Хранилище.** `/srv/projects/AI-Memory`, Obsidian 1.13.7 из snap, 218 заметок, 2,7 МБ. Структура `00_SYSTEM`, `01_PROJECTS`, `02_KNOWLEDGE`, `03_LESSONS`, `04_PATTERNS`, `05_AGENTS`, `99_ARCHIVE`. Заметки размечены полями (`type`, `severity`, `confidence`, `created_by`, `reviewed_by`). Есть протокол, политика памяти, роли и три шаблона.
|
||||
|
||||
Приложение Obsidian нужно человеку. **Агенту достаточно пути**: хранилище — это папка с файлами Markdown, никаких плагинов и серверов поднимать не надо.
|
||||
|
||||
**Память проекта устарела на четыре дня и тринадцать заданий.**
|
||||
|
||||
```
|
||||
01_PROJECTS/hermes-hub/CURRENT_STATE.md обновлён 27 августа
|
||||
в нём: main = c35bc48, идёт работа над A33
|
||||
на деле: main = 80aab00, идёт A46
|
||||
worklog/ ПУСТО, 0 записей
|
||||
```
|
||||
|
||||
Протокол требует после каждой задачи обновить `CURRENT_STATE.md`, `HANDOFF.md`, `TASKS.md` и написать worklog. Не делалось ни разу.
|
||||
|
||||
**Мосты.** В репозиториях `agent-control-center`, `business-platform`, `finance-*`, `hermes-android` и других `AGENTS.md` есть и на AI-Memory ссылается. Исключением был `hermes-hub`; корневой мост добавлен ревьюером в `80aab00`, но **на сервере лежит старая копия** — нужен `git pull`. Без моста также `hermes-hub-a34`.
|
||||
|
||||
**Хранилище не под контролем версий.** `.git` нет, истории нет, отката нет. При этом папка доступна на запись нескольким агентам сразу.
|
||||
|
||||
---
|
||||
|
||||
## P0-1. Привести память проекта в соответствие с действительностью
|
||||
|
||||
Не переписывать заново — обновить.
|
||||
|
||||
1. `CURRENT_STATE.md`: текущий `main`, ветки в работе, что сделано за A40–A46, что открыто.
|
||||
2. `HANDOFF.md`: с чего продолжать.
|
||||
3. `TASKS.md`: состояние заданий A40–A47.
|
||||
4. `DECISIONS.md`: решения, принятые за эти дни, — отказ от `screenshot-to-code` и почему; клиент без сборки; версия `0.1.1` заморожена намеренно; порог 64К у Hermes.
|
||||
5. **Даты и коммиты обязательны** у каждой записи. Память без даты нельзя отличить от свежей, и агент поверит устаревшей.
|
||||
|
||||
Уроки за эти дни оформить по `LESSON_TEMPLATE.md`, минимум эти:
|
||||
|
||||
```
|
||||
мнимый успех: действие вернуло ok:True и не сохранило ничего
|
||||
клиент читал несуществующий ключ снапшота (profiles вместо all_profiles)
|
||||
заглушка sleep 3600 вместо llama-server оставлена на рабочей машине
|
||||
работа принята по зелёным тестам без единого взгляда на экран
|
||||
зашитые идентификаторы профилей: убраны в A41, возвращены в A42
|
||||
```
|
||||
|
||||
## P0-2. Хранилище под контроль версий
|
||||
|
||||
Сейчас это папка без истории, куда пишут несколько агентов. Одна ошибочная перезапись — и восстановить нечем.
|
||||
|
||||
1. Завести git **локально**, без публичного удалённого репозитория: в памяти обсуждается внутреннее устройство систем владельца.
|
||||
2. `.gitignore` для служебного каталога `.obsidian/workspace*` и прочего, что меняется от открытия окна.
|
||||
3. Ежедневный коммит-снимок либо коммит после изменений — на выбор, но обосновать.
|
||||
4. **Проверить восстановление**: испортить копию файла, вернуть из истории.
|
||||
5. Учётные данные, токены и пути к ним в память не писать — проверить, что их там нет уже сейчас.
|
||||
|
||||
## P0-3. Единый мозг: все агенты читают одно
|
||||
|
||||
Смысл в том, чтобы урок, полученный одним агентом, работал у остальных.
|
||||
|
||||
1. **Составить перечень**, какие ИИ действительно работают на сервере и каким файлом каждый настраивается. У разных инструментов это разные имена (`AGENTS.md`, `CLAUDE.md` и другие) — выяснить, а не предположить.
|
||||
2. **Каждому дать мост** на `/srv/projects/AI-Memory` по образцу `00_SYSTEM/AGENTS_BRIDGE_PLAN.md`: короткий указатель, без копий уроков.
|
||||
3. **Обновить копию `hermes-hub` на сервере** (`git pull`), чтобы корневой мост из `80aab00` там появился.
|
||||
4. **`hermes-hub-a34`** — выяснить, живой ли это рабочий каталог. Если остаток — убрать; если рабочий — дать мост.
|
||||
5. **Правило единственности.** Уроки и решения живут только в AI-Memory. Копия в репозитории — дефект: копии расходятся, и агент читает неверную.
|
||||
|
||||
## P0-4. Обновление памяти — часть завершения задачи
|
||||
|
||||
Иначе всё вернётся к нынешнему состоянию.
|
||||
|
||||
1. Внести в шаблон задания два обязательных пункта: **прочитать память до работы**, **обновить после**.
|
||||
2. В отчёт добавить строку: какие файлы памяти обновлены и каким уроком пополнилась база.
|
||||
3. **Проверка свежести**: способ увидеть, что `CURRENT_STATE.md` отстал от `main`. Достаточно скрипта, сравнивающего записанный коммит с текущим, и предупреждения при расхождении.
|
||||
4. **Всё хранилище в контекст не загружать** — 218 заметок. Читать `00_SYSTEM`, файлы своего проекта и найденное поиском по теме.
|
||||
|
||||
## P0-5. Несколько агентов пишут одновременно
|
||||
|
||||
Общая папка на запись без разграничения уже дала в этом проекте два случая: агент переключил ветку под чужой работой, и на рабочей машине осталась подменённая заглушка.
|
||||
|
||||
1. **Одновременная запись в один файл не должна терять правки.** Предложить механизм и обосновать: раздельные файлы worklog на агента, дозапись вместо перезаписи, блокировка.
|
||||
2. **Каждая запись подписана**: кто, когда, по какому заданию. Поле `created_by` в шаблоне уже есть — использовать.
|
||||
3. **Чужие записи не переписывать.** Не согласен — добавить свою и сослаться на исходную.
|
||||
|
||||
## P0-6. Граница: память — это данные, а не канал команд
|
||||
|
||||
Отдельным пунктом, потому что цена ошибки высока и проект этим уже занимался в A37.
|
||||
|
||||
Общая папка, из которой все агенты читают инструкции и в которую все пишут, — это ровно тот канал связи между агентами, о котором предупреждал разбор чужого инцидента, приложенный к A37: разрешённый внутренний сервис становится доской объявлений и точкой опоры.
|
||||
|
||||
1. **Память описывает состояние и уроки. Она не отдаёт распоряжений.** Задания приходят от владельца через `agents/inbox/`, а не из заметок.
|
||||
2. **Заметка, требующая действия, исполнением не является.** Найденный в памяти «TODO» выносится владельцу, а не выполняется молча.
|
||||
3. **Изменения, расширяющие права или меняющие правила работы агентов**, вносит владелец. Агент может предложить.
|
||||
4. Подписи и даты из P0-5 нужны и для этого: должно быть видно, кто внёс запись.
|
||||
|
||||
## P0-7. Аудит вторым проходом
|
||||
|
||||
1. **Сверить `CURRENT_STATE.md` с действительностью**: коммит в памяти против `git log` на сервере.
|
||||
2. **Проверить восстановление из истории** самостоятельно, а не по описанию.
|
||||
3. **Проверить, что мост есть у каждого перечисленного агента** и указывает на существующий путь.
|
||||
4. **Искать копии уроков** в репозиториях — их быть не должно.
|
||||
5. **Искать учётные данные** в памяти целенаправленно.
|
||||
6. **Проверить, что заметки не отдают распоряжений** агентам.
|
||||
7. **Побочные изменения** объяснить.
|
||||
8. **Пропущенный пункт назвать пропущенным.**
|
||||
|
||||
---
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Хранилище **не публиковать**: ни на GitHub, ни куда-либо ещё.
|
||||
- Существующие заметки владельца не удалять и не переписывать; устаревшее переносить в `99_ARCHIVE`.
|
||||
- Структуру папок и разметку полей не менять — она рабочая.
|
||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
||||
- Службы `qwen-coder` и `qwen-compressor` не трогать — они только что восстановлены.
|
||||
- Версию `0.1.1` не поднимать.
|
||||
- Правило честности без исключений.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Ветка в `origin`, `git status` чист.
|
||||
2. `CURRENT_STATE.md`, `HANDOFF.md`, `TASKS.md`, `DECISIONS.md` проекта соответствуют действительности; у записей есть даты и коммиты.
|
||||
3. Уроки из P0-1 оформлены по шаблону.
|
||||
4. Хранилище под git локально; восстановление файла из истории проверено, вывод приложен.
|
||||
5. Перечень ИИ сервера составлен; у каждого мост на AI-Memory; пути существуют.
|
||||
6. Копия `hermes-hub` на сервере обновлена, корневой мост на месте; судьба `hermes-hub-a34` решена.
|
||||
7. Копий уроков в репозиториях нет.
|
||||
8. Шаблон задания содержит пункты про чтение и обновление памяти.
|
||||
9. Проверка свежести работает: расхождение памяти с `main` обнаруживается.
|
||||
10. Механизм одновременной записи предложен, обоснован и проверен.
|
||||
11. Учётных данных в памяти нет; проверено поиском.
|
||||
12. `ruff check .` чисто; релизный гейт не ухудшен.
|
||||
13. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`. На `origin/main` сейчас **496 passed**.
|
||||
|
||||
## Главное
|
||||
|
||||
Память построена, размечена и продумана — а последняя запись в ней сделана 27 августа, и каталог worklog пуст. Тринадцать заданий прошли мимо. Агент, который добросовестно её прочитает, начнёт работать по состоянию четырёхдневной давности: решит, что идёт A33 и `main` — это `c35bc48`. Устаревшая память вреднее отсутствующей, потому что ей верят.
|
||||
|
||||
Задание про то, чтобы память стала живой: обновлялась как часть работы, была одинаково видна всем агентам и пережила ошибочную перезапись.
|
||||
|
||||
## Порядок сдачи
|
||||
Передать точный `FINAL_COMMIT_SHA`.
|
||||
174
agents/inbox/2026-08-31-A49-subagents-skills-memory.md
Normal file
174
agents/inbox/2026-08-31-A49-subagents-skills-memory.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# Задание A49: расстановка субагентов, вкладка «Скиллы», память через Obsidian
|
||||
|
||||
## Дата поступления
|
||||
2026-08-31
|
||||
|
||||
## База
|
||||
|
||||
`origin/main` (`17b368a`) — туда слиты A42, A45, A47 и A48.
|
||||
|
||||
```
|
||||
git fetch origin --prune
|
||||
git checkout -b antigravity/a49-subagents-skills-memory origin/main
|
||||
```
|
||||
|
||||
В `main` напрямую не пушить.
|
||||
|
||||
## Порядок исполнения
|
||||
|
||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан для аудитора.
|
||||
|
||||
Задание крупное и делится на три независимые части. **Части можно сдавать по отдельности**, но каждую — целиком.
|
||||
|
||||
Не пересекается с A44 (сервер) и A45 (замеры). Вёрстка A48 уже в `main`: новые экраны делать в её стиле, существующие не ломать.
|
||||
|
||||
---
|
||||
|
||||
## Что проверено ревьюером
|
||||
|
||||
**Ролей объявлено тринадцать**, соединено пять.
|
||||
|
||||
```
|
||||
manager developer-1 developer-2 code-reviewer researcher tester
|
||||
tech-writer analyst guardian cost-controller integration-expert
|
||||
security-expert dependency-agent
|
||||
```
|
||||
|
||||
Конвейер по умолчанию связывает только `manager → developer-1 → developer-2 → code-reviewer` с возвратами по `REVIEW_FAILED`. Остальные восемь ролей объявлены, но в графе висят без связей: на экране владельца `Research` и `Fast` стоят в стороне и ни к чему не присоединены.
|
||||
|
||||
**Скиллов в интерфейсе нет вовсе.** Ни вкладки, ни поля в инспекторе агента, ни признака, пользовался ли агент скиллом.
|
||||
|
||||
**Общая память уже работает** после A47: `/srv/projects/AI-Memory` под git, структура `00_SYSTEM`, `01_PROJECTS`, `03_LESSONS`, `04_PATTERNS`, `05_AGENTS`, протокол и шаблоны на месте, `worklog` заполняется. Корневой `AGENTS.md` в репозитории указывает на неё.
|
||||
|
||||
**Obsidian** стоит на сервере (snap 1.13.7), но **агенту он не нужен**. Из руководства владельца по подключению Obsidian к агенту, дословно: «Агенту нужен не GUI Obsidian, а локальная папка vault». Хранилище — это папка с файлами Markdown.
|
||||
|
||||
---
|
||||
|
||||
# Часть 1. Расстановка субагентов и связи
|
||||
|
||||
## P0-1. Разобрать всех тринадцать и соединить
|
||||
|
||||
1. **Разбор каждой роли**: что делает, от кого получает работу, кому передаёт, по какому условию. Приложить таблицей.
|
||||
2. **Связать те, что должны работать вместе.** Восемь ролей сейчас ни с чем не соединены — для каждой либо связь, либо явная запись «работает по вызову, в конвейер не входит» с обоснованием.
|
||||
3. **Условия переходов** брать из существующего набора: `SUCCESS`, `REVIEW_PASSED`, `REVIEW_FAILED`, `NEXT`, `ERROR`, `ALWAYS`. Новые вводить только при необходимости и объяснять.
|
||||
4. **Циклы доработки конечны.** Возврат `REVIEW_FAILED` без ограничения числа итераций — это бесконечный круг на живых квотах. Предел итераций уже есть в конвейере — проверить, что он соблюдается на каждом возврате.
|
||||
5. **Расстановка на холсте осмысленная**: поток слева направо, возвраты видимой дугой, узлы не наезжают друг на друга. После A48 подписи связей читаются — не сломать.
|
||||
|
||||
**Ничего не выдумывать про роли.** Назначение брать из `role_registry.py`; если для роли нет внятного места в потоке, так и написать, а не придумывать ей работу.
|
||||
|
||||
---
|
||||
|
||||
# Часть 2. Вкладка «Скиллы»
|
||||
|
||||
## P0-2. Скиллы видны, ищутся и назначаются
|
||||
|
||||
1. **Новая вкладка «Скиллы»** в главном меню, в стиле экранов A48.
|
||||
2. **Список установленных скиллов** — читать из каталога скиллов агента (`~/.claude/skills/` и равнозначные для других инструментов; путь настраивается). Показывать `name`, `description` и путь.
|
||||
3. **Поиск** по имени и описанию.
|
||||
4. **Назначение скилла субагенту** — из вкладки и из карточки агента. Назначения сохраняются и переживают перезапуск.
|
||||
5. **Во вкладке «Инструменты» инспектора** показывать назначенные скиллы. Сейчас там `Н/Д: инструменты не назначены` — это состояние должно наполниться.
|
||||
6. **Скилл не найден или каталог отсутствует** — сказать об этом с причиной и путём, где искали. Не показывать пустой список как «скиллов нет».
|
||||
|
||||
## P0-3. Видно, пользовался ли агент скиллом
|
||||
|
||||
Владелец: «добавить режим просмотра, использовал он в проекте скиллы или сам придумывал».
|
||||
|
||||
1. **Записывать факт применения**: какой скилл, каким агентом, в какой задаче, когда.
|
||||
2. **Показывать в истории агента** и отдельным срезом по проекту: применённые скиллы против назначенных, но ни разу не сработавших.
|
||||
3. **Назначен и ни разу не применён — это сигнал**, а не ошибка. Показывать как факт: скилл может не подходить под задачи, а может быть сломан — второе лечится частью P0-4.
|
||||
4. **Правило честности здесь особенно важно.** Если признак применения снять неоткуда — писать `Н/Д` с причиной, а не рисовать правдоподобную статистику. Сначала выяснить, что вообще можно узнать достоверно, и в отчёте назвать источник.
|
||||
|
||||
## P0-4. Субагент «скилл-доктор»
|
||||
|
||||
Готовый скилл лежит у владельца: `Desktop/skills-hermes/skill-doctor/` — `SKILL.md` и `references/description-cookbook.md`. **Написан, выверен и переделке не подлежит**; задание — встроить его как роль.
|
||||
|
||||
Главное из него, что определяет устройство роли:
|
||||
|
||||
- **У скилла две независимые части.** `frontmatter` (`name`, `description`) решает, **запустится** ли скилл; тело решает, **что будет после запуска**. Чинить тело, когда сломано описание, — самая частая потеря времени.
|
||||
- **Порядок диагностики:** формальное (имя файла ровно `SKILL.md`, расположение, границы `---`, `name` латиницей, `description` одной строкой) → разбор описания на три части → тело → проверочные запросы → диагноз.
|
||||
- **Многострочный `description` — ошибка номер один по частоте**: YAML обрезает его, и решение о запуске принимается по огрызку.
|
||||
- **Описание состоит из трёх частей**: что делает, когда запускать (реальными словами пользователя, 4–5 формулировок), когда **НЕ** запускать. Третья отсутствует почти всегда, и без неё скилл тихо срабатывает на соседних темах и жжёт лимиты — это хуже молчания, потому что не замечается.
|
||||
- **Пять проверочных запросов**: три должны запустить скилл, два — не запустить. Негативные обязательны.
|
||||
- **Диагноз выдаётся строгим форматом** с готовым `description` целиком, а не советом «сделай понятнее».
|
||||
|
||||
Требования к встраиванию:
|
||||
|
||||
1. **Новая каноническая роль** `skill-doctor` в реестре, с назначением и способностями, как у остальных.
|
||||
2. **Запуск из вкладки «Скиллы»**: кнопка «Проверить скилл» рядом с каждым, и общая проверка всех.
|
||||
3. **Результат показывать в интерфейсе** тем же форматом диагноза, с готовым описанием, которое можно скопировать.
|
||||
4. **Скилл-доктор не правит файлы молча.** Он ставит диагноз и предлагает правку; применяет её владелец.
|
||||
|
||||
---
|
||||
|
||||
# Часть 3. Память через Obsidian
|
||||
|
||||
## P0-5. Хранилище подключается и наполняется
|
||||
|
||||
Владелец: «если на ПК или сервере установлен Обсидиан, то должен подгружаться в память… в настройках добавляешь папку рабочую Обсидиан, и оркестратору даёшь задание, чтобы он настроил работу».
|
||||
|
||||
1. **Обнаружение.** Хаб проверяет, есть ли Obsidian и хранилище. Признак хранилища — **папка с каталогом `.obsidian` внутри**, а не установленное приложение: агенту нужна папка, не программа. Найдено — предложить; не найдено — сказать прямо, без догадок.
|
||||
2. **Настройка пути** в «Настройках»: путь к хранилищу задаётся вручную и сохраняется. На сервере владельца это `/srv/projects/AI-Memory`.
|
||||
3. **Проверка при сохранении**: путь существует, доступен на запись, внутри есть `.obsidian`. Иначе — отказ с причиной.
|
||||
4. **Хранилища нет — хаб работает как прежде.** Память не должна стать обязательной.
|
||||
|
||||
## P0-6. Оркестратор раскладывает память по структуре
|
||||
|
||||
1. **Действие «Настроить память»**, запускающее оркестратора по заложенной структуре. Структура **уже существует** — та, что в `/srv/projects/AI-Memory`: `00_SYSTEM`, `01_PROJECTS/<проект>/`, `03_LESSONS`, `04_PATTERNS`, `05_AGENTS`. Использовать её, а не изобретать вторую.
|
||||
2. **У каждого субагента во вкладке «Память»** — своя структура по проектам: что он читает перед работой, что записывает после, его записи в `worklog` и его уроки.
|
||||
3. **Существующие заметки владельца не трогать.** 218 заметок и восемь записей `worklog` уже есть; устаревшее переносить в `99_ARCHIVE`, не удалять.
|
||||
4. **Разделение чтения и записи.** Субагент читает общее, пишет своё. Каждая запись подписана: кто, когда, по какому заданию.
|
||||
5. **Граница остаётся.** Память описывает состояние и уроки; **распоряжений она не отдаёт**. Задание приходит от владельца, а не из заметки. Это требование A47, и оно не отменяется тем, что памятью теперь управляет оркестратор.
|
||||
|
||||
---
|
||||
|
||||
## P0-7. Аудит вторым проходом
|
||||
|
||||
1. **Открыть хаб и посмотреть** новую вкладку и связи на холсте. Не отчёт — экран. Скриншоты приложить, как в A48.
|
||||
2. **Проверить, что список скиллов настоящий**: подложить скилл в каталог и убедиться, что он появился; убрать — исчез.
|
||||
3. **Скилл-доктор проверить на заведомо сломанном скилле** — с многострочным `description` — и убедиться, что диагноз указывает именно на это.
|
||||
4. **Признак применения скилла**: убедиться, что он снимается измерением, а не выводится из назначения.
|
||||
5. **Проверить, что без Obsidian хаб работает** как прежде.
|
||||
6. **Проверить, что заметки владельца не пострадали**: число заметок до и после.
|
||||
7. **Циклы доработки конечны** — убедиться, что предел итераций соблюдается.
|
||||
8. **Побочные изменения** объяснить.
|
||||
9. **Пропущенный пункт назвать пропущенным.**
|
||||
|
||||
---
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Клиент **без сборки, без npm, без фреймворка**.
|
||||
- Вёрстку A48 не ломать; новые экраны — в её стиле.
|
||||
- Учётные данные, `~/.hermes/agy_profiles/`, службы `qwen-coder` и `qwen-compressor` не трогать.
|
||||
- Заметки владельца не удалять.
|
||||
- Скилл-доктор из `Desktop/skills-hermes/skill-doctor/` не переписывать.
|
||||
- Версию `0.1.1` не поднимать.
|
||||
- Правило честности без исключений: не измерено — `Н/Д` с причиной.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Ветка в `origin`, `git status` чист.
|
||||
2. Разбор тринадцати ролей приложен таблицей; каждая либо соединена, либо объявлена внеконвейерной с обоснованием.
|
||||
3. Циклы доработки конечны; проверено.
|
||||
4. Вкладка «Скиллы» есть: список читается из каталога, поиск работает, назначение сохраняется и переживает перезапуск.
|
||||
5. Назначенные скиллы видны в инспекторе агента.
|
||||
6. Видно, применялся ли скилл; источник признака назван; неизмеримое помечено `Н/Д`.
|
||||
7. Роль `skill-doctor` в реестре; запуск из интерфейса; диагноз выводится строгим форматом с готовым описанием; файлы молча не правятся.
|
||||
8. Скилл-доктор проверен на заведомо сломанном скилле.
|
||||
9. Хранилище Obsidian обнаруживается по наличию `.obsidian`, путь настраивается и проверяется.
|
||||
10. Без хранилища хаб работает как прежде.
|
||||
11. Оркестратор раскладывает память по существующей структуре; у каждого субагента во вкладке «Память» видна структура по проектам.
|
||||
12. Заметки владельца целы; число до и после совпадает.
|
||||
13. Скриншоты новых экранов приложены.
|
||||
14. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **517**.
|
||||
15. Память проекта в AI-Memory обновлена.
|
||||
16. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
|
||||
|
||||
## Главное
|
||||
|
||||
Тринадцать субагентов объявлено, работают пятеро, восемь висят на холсте без связей. Скиллы владелец ставит руками и не видит ни списка, ни того, пользовался ими агент или писал по наитию. Память после A47 ожила, но субагенты в неё не смотрят.
|
||||
|
||||
Задание сводит три вещи в одно: агенты расставлены и связаны осмысленно, у каждого свои скиллы с проверкой их исправности, и все читают одну память по структуре, которую раскладывает оркестратор.
|
||||
|
||||
## Порядок сдачи
|
||||
Передать точный `FINAL_COMMIT_SHA`.
|
||||
166
agents/inbox/2026-08-31-A51-hub-controls-hermes.md
Normal file
166
agents/inbox/2026-08-31-A51-hub-controls-hermes.md
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# Задание A51: подключённый аккаунт должен реально использоваться Hermes
|
||||
|
||||
## Дата поступления
|
||||
2026-08-31
|
||||
|
||||
## База
|
||||
|
||||
`origin/main` (`17b368a`).
|
||||
|
||||
```
|
||||
git fetch origin --prune
|
||||
git checkout -b antigravity/a51-hub-controls-hermes origin/main
|
||||
```
|
||||
|
||||
В `main` напрямую не пушить.
|
||||
|
||||
## Порядок исполнения
|
||||
|
||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан для аудитора.
|
||||
|
||||
Зона: маршрутизация, назначение ролей, плагин Hermes, карточка аккаунта. С A49 (скиллы и память) и A50 (обнаружение и проверка) не пересекается по смыслу, но **трогает те же файлы, что A50** — `auto_assigner.py` и экран «Аккаунты». Выполнять **после A50**.
|
||||
|
||||
---
|
||||
|
||||
## Задача
|
||||
|
||||
Владелец: «в хабе я поставил аккаунт аги. Когда я захожу в Гермеса, какой аккаунт будет выбран? И если я поменяю в Гермесе аккаунт, поменяется он в хабе? Иначе толку от хаба, если в самом Гермесе это не работает».
|
||||
|
||||
Ответ, проверенный ревьюером: **сейчас не будет выбран ни один из его аккаунтов**.
|
||||
|
||||
---
|
||||
|
||||
## Что проверено исполнением
|
||||
|
||||
**Определение роли работает.** A35 встал: плагин перехватывает каждый `llm_execution` и определяет роль четырьмя уровнями — явная, по модели и провайдеру, по устойчивости сессии, по умолчанию. Это переделке не подлежит.
|
||||
|
||||
**Но цепочки указывают в пустоту.** Конфигурация владельца на рабочей машине:
|
||||
|
||||
```
|
||||
default_role: не задан → используется manager
|
||||
manager → ag-orch-fallback, codex-orch, opengo-3
|
||||
developer-1 → ag-w1, codex-worker-1, opengo-3
|
||||
researcher → opengo-1, ag-w3, ag-w4
|
||||
|
||||
профилей в конфигурации: 24
|
||||
ролей: 13
|
||||
```
|
||||
|
||||
Проверка вхождения **подключённых** аккаунтов владельца в цепочки:
|
||||
|
||||
```
|
||||
ollama-1 НИ В ОДНОЙ
|
||||
grok-1 НИ В ОДНОЙ
|
||||
local-2 НИ В ОДНОЙ
|
||||
local-3 НИ В ОДНОЙ
|
||||
antigravity-1 НИ В ОДНОЙ
|
||||
```
|
||||
|
||||
Все тринадцать ролей по-прежнему ссылаются на заготовки из старой конфигурации на 24 слота, а они не настроены. Значит при обращении Hermes цепочка `manager` перебирает три неавторизованных слота, отказывает, и плагин — правильно, по своему устройству — **пропускает вызов мимо хаба дальше в Hermes**:
|
||||
|
||||
```python
|
||||
if isinstance(completion, dict) and completion.get("router_error"):
|
||||
logger.warning("Router failover exhausted for role %r; passing the call downstream to Hermes")
|
||||
```
|
||||
|
||||
Хаб при этом ведёт себя корректно: он не подменяет ответ. Но результат для владельца тот самый, которого он опасается — **хаб не участвует в работе вовсе**.
|
||||
|
||||
**Карточка аккаунта показывает роль, которой нет.** На экране `ollama-1` подписан «manager (primary)», хотя в цепочке `manager` его нет. Источник подписи — `auto_assigner.get_display_name_and_role`, а она читает **статическую таблицу** `DEFAULT_SLOT_ROLES`, а при промахе достраивает подпись из имени провайдера. В снапшот это попадает так:
|
||||
|
||||
```python
|
||||
assigned_roles=role_assignments.get(pid, [log_role])
|
||||
```
|
||||
|
||||
Есть аккаунт в живой цепочке — берётся живое значение; нет — подставляется **догадка**. Владелец видит «manager (primary)» и считает, что аккаунт назначен.
|
||||
|
||||
Тот же аккаунт на карточке списка подписан «worker», а в окне — «manager (primary)». Два разных источника в двух местах.
|
||||
|
||||
**Обратной синхронизации нет.** `_select_model` в `hermes_plugin.py` — ручная команда CLI, которая записывает в конфигурацию Hermes провайдера, модель и адрес. Ничего, что читало бы выбор владельца, сделанный **внутри** Hermes, и переносило бы его в хаб, в коде нет.
|
||||
|
||||
---
|
||||
|
||||
## P0-1. Подключённый аккаунт попадает в цепочку
|
||||
|
||||
1. **Подключение аккаунта ставит его в цепочку выбранной роли.** Роль владелец выбирает на третьем шаге мастера — сейчас этот выбор до цепочки не доходит.
|
||||
2. **Если аккаунт никуда не назначен — так и писать.** «Не назначен» — нормальное состояние, но оно должно быть видно, а не подменяться догадкой.
|
||||
3. **Кнопка «Авто»**: разложить подключённые аккаунты по ролям по способностям провайдера. Предложить расстановку и **показать до применения**, а не применять молча.
|
||||
|
||||
## P0-2. Карточка показывает то, что есть на самом деле
|
||||
|
||||
1. **Роль на карточке берётся только из живой цепочки.** Подстановка из `DEFAULT_SLOT_ROLES` в качестве роли — убрать: статическая таблица годится для человекочитаемого имени, но не для утверждения о назначении.
|
||||
2. **Один источник для карточки и окна.** Сейчас список пишет «worker», окно — «manager (primary)».
|
||||
3. **Показывать место в цепочке**: основной или запасной номер такой-то. «primary» без указания, в какой роли и на каком месте, ничего не значит.
|
||||
|
||||
## P0-3. Владелец видит, кто ответит на вызов
|
||||
|
||||
Главное, ради чего задание.
|
||||
|
||||
1. **На экране маршрутизации у каждой роли — «сейчас ответит: <аккаунт>»**, вычисленное по текущей цепочке и состоянию аккаунтов.
|
||||
2. **Если не ответит никто** — сказать прямо: «цепочка пуста или все аккаунты недоступны, вызов уйдёт мимо хаба в Hermes». Это состояние сейчас и есть, и владелец о нём не знает.
|
||||
3. **Роль по умолчанию видна и настраивается.** Сейчас `default_role` не задан, и молча используется `manager`. Показать это в настройках.
|
||||
|
||||
## P0-4. Видно, прошёл вызов через хаб или мимо
|
||||
|
||||
1. **Записывать по каждому вызову**: определилась ли роль, каким уровнем, какой профиль выбран, ушёл ли вызов мимо хаба и почему.
|
||||
2. **Показывать в журнале событий** и счётчиком на «Обзоре»: сколько вызовов прошло через хаб, сколько мимо.
|
||||
3. Это единственный способ ответить на вопрос владельца «работает ли хаб» измерением, а не рассуждением.
|
||||
|
||||
## P0-5. Обратная связь с Hermes
|
||||
|
||||
Владелец: «если я поменяю в Гермесе аккаунт, поменяется он в хабе?»
|
||||
|
||||
Сейчас — нет. Прежде чем делать, **выяснить и записать в отчёт**, что именно Hermes позволяет наблюдать: что хранится в его конфигурации, меняется ли она при выборе модели в интерфейсе, есть ли событие или файл, по которому это видно.
|
||||
|
||||
Дальше по результату:
|
||||
|
||||
1. **Если выбор Hermes читается** — показывать его в хабе и отмечать расхождение с цепочкой: «в Hermes выбран X, хаб направил бы на Y».
|
||||
2. **Если не читается** — так и написать, а в интерфейсе объяснить владельцу, что хаб управляет маршрутом только когда Hermes не задаёт провайдера явно. Честное объяснение принимается.
|
||||
3. **Ничего не записывать в конфигурацию Hermes автоматически.** `_select_model` остаётся ручной командой: молчаливая правка чужой конфигурации — это то, за что уже возвращались работы.
|
||||
4. **Не выдумывать механизм**, которого в Hermes нет. Отсутствие способа — результат, он принимается.
|
||||
|
||||
## P0-6. Аудит вторым проходом
|
||||
|
||||
1. **Пройти путь целиком на живой машине**: подключить аккаунт, назначить роль, сделать запрос через Hermes и убедиться по журналу, что вызов пошёл через хаб и через **этот** аккаунт. Это единственная настоящая проверка задания.
|
||||
2. **Проверить обратное**: убрать аккаунт из цепочки и убедиться, что вызов уходит мимо хаба и это видно в интерфейсе.
|
||||
3. **Проверить, что роль на карточке исчезает**, когда аккаунт не назначен, — а не подменяется догадкой.
|
||||
4. **Сверить карточку и окно**: подпись роли одинакова.
|
||||
5. **Проверить конфигурацию владельца на копии**: старые цепочки на 24 заготовки не должны молча пропасть; предложить перенос, но не выполнять его без подтверждения.
|
||||
6. **Побочные изменения** объяснить.
|
||||
7. **Пропущенный пункт назвать пропущенным.**
|
||||
|
||||
---
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Конфигурацию владельца молча не переписывать: перенос цепочек — только с подтверждением.
|
||||
- В конфигурацию Hermes автоматически не писать.
|
||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
||||
- Определение роли из A35 не переделывать.
|
||||
- Вёрстку A48 не ломать.
|
||||
- Версию `0.1.1` не поднимать.
|
||||
- Правило честности без исключений.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Ветка в `origin`, `git status` чист.
|
||||
2. Подключение аккаунта с выбором роли кладёт его в цепочку этой роли; проверено.
|
||||
3. Неназначенный аккаунт показан как неназначенный; догадка из статической таблицы как роль не используется.
|
||||
4. Карточка и окно аккаунта показывают одну и ту же роль и место в цепочке.
|
||||
5. На маршрутизации видно, какой аккаунт ответит для каждой роли; пустая цепочка названа прямо.
|
||||
6. Роль по умолчанию видна и настраивается.
|
||||
7. По журналу видно, прошёл вызов через хаб или мимо и почему; есть счётчик.
|
||||
8. Пройден живой путь: подключение, назначение, запрос через Hermes, подтверждение по журналу.
|
||||
9. Выяснено и записано, что Hermes позволяет наблюдать о своём выборе; сделано либо честно объявлено невозможным.
|
||||
10. Конфигурация владельца не изменена без подтверждения.
|
||||
11. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **517**.
|
||||
12. Память проекта в AI-Memory обновлена.
|
||||
13. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
|
||||
|
||||
## Главное
|
||||
|
||||
Владелец подключил аккаунты, увидел на карточках «manager (primary)» и решил, что настроил маршрутизацию. На деле ни один его аккаунт не входит ни в одну цепочку: все тринадцать ролей ссылаются на заготовки старой конфигурации. При обращении Hermes цепочка отказывает, и вызов уходит мимо хаба.
|
||||
|
||||
Хаб ведёт себя корректно и не подменяет ответ. Но владелец об этом не знает и считает, что управляет маршрутизацией, а управляет пустотой. Задание должно сделать так, чтобы назначение действительно назначало, а расхождение было видно на экране, а не выяснялось разбором конфигурации.
|
||||
|
||||
## Порядок сдачи
|
||||
Передать точный `FINAL_COMMIT_SHA`.
|
||||
295
agents/inbox/2026-08-31-A52-local-model-supervisor.md
Normal file
295
agents/inbox/2026-08-31-A52-local-model-supervisor.md
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
# Задание A52: локальные модели — замена, надзиратель, пара кодеров с облачным судьёй
|
||||
|
||||
## Дата поступления
|
||||
2026-08-31 (переработано в тот же день: добавлены части 1 и 3)
|
||||
|
||||
## База
|
||||
|
||||
`origin/main` (`17b368a`).
|
||||
|
||||
```
|
||||
git fetch origin --prune
|
||||
git checkout -b antigravity/a52-local-models origin/main
|
||||
```
|
||||
|
||||
В `main` напрямую не пушить.
|
||||
|
||||
## Порядок исполнения
|
||||
|
||||
Два прохода: **Flash** исполняет, **Pro** проводит аудит. Пункт **P0-10** написан для аудитора.
|
||||
|
||||
Задание из трёх частей, и они идут **строго по порядку**:
|
||||
|
||||
```
|
||||
Часть 1 замена моделей и замеры сдаётся отдельно, дальше по её числам
|
||||
Часть 2 надзиратель локальных моделей
|
||||
Часть 3 пара кодеров и облачный судья
|
||||
```
|
||||
|
||||
Часть 3 планировать **по измеренным числам части 1**, а не заранее: без замера видеопамяти схема не проверяема.
|
||||
|
||||
Связано с A49 (расстановка субагентов и память) и A51 (аккаунт реально используется). Выполнять после них: надзирателю нужны и место в графе, и работающее назначение.
|
||||
|
||||
---
|
||||
|
||||
## Что проверено ревьюером — заново не выяснять
|
||||
|
||||
### Видеопамять занята почти полностью
|
||||
|
||||
```
|
||||
Qwen3.8-27B кодер @196K 25 488 МиБ
|
||||
qwen3-4b компрессор @32K 5 368 МиБ
|
||||
──────────
|
||||
30 856 из 32 768 свободно 1 912
|
||||
```
|
||||
|
||||
### Измерено у кандидатов (A45, файлы и контрольные суммы сверены по диску)
|
||||
|
||||
```
|
||||
Qwen3-Coder-30B-A3B @64K 21 368 МиБ 109,6 ток/с 83,3% MoE 30B/3B
|
||||
Qwen3-Coder-30B-A3B @32K 19 640 МиБ 110,2 ток/с
|
||||
Qwen2.5-Coder-32B @64K 27 938 МиБ 29,3 ток/с 83,3% плотная
|
||||
```
|
||||
|
||||
Расход контекста у Qwen3-Coder — около 54 КиБ на токен.
|
||||
|
||||
### Качество на стенде из 12 задач (A40, признано)
|
||||
|
||||
```
|
||||
Phi-4-14B 83,3% файл 8,28 ГиБ
|
||||
Qwen2.5-Coder-14B 75,0% файл 8,37 ГиБ
|
||||
Qwen3-4B-2507 83,3% файл 2,33 ГиБ
|
||||
Granite-4.2-8B 66,7% файл 5,16 ГиБ
|
||||
```
|
||||
|
||||
**Расход видеопамяти у 14B при 64К не измерен ни разу.** В A45 их не было, столбец VRAM из A40 непригоден: он собрал занятость всей карты, а не процесса.
|
||||
|
||||
### Процессор годится для служебных ролей
|
||||
|
||||
Замер ревьюера, 32 потока из 72, AVX2:
|
||||
|
||||
```
|
||||
LFM2.5-2.6B промпт 1150,8 ток/с генерация 13,4 ток/с
|
||||
Qwen3-4B промпт 854,2 ток/с генерация 8,7 ток/с
|
||||
```
|
||||
|
||||
Обработка промпта на процессоре быстрая, генерация медленная: она упирается в память и идёт последовательно.
|
||||
|
||||
### Сервер отдаёт всё нужное для честной работы
|
||||
|
||||
Проверено на живом `127.0.0.1:8081`:
|
||||
|
||||
```
|
||||
GET /props → default_generation_settings.n_ctx = 196608, total_slots = 1
|
||||
POST /tokenize → точное число токенов («def add(a, b): return a + b» = 10)
|
||||
```
|
||||
|
||||
Предел контекста и размер задания **измеряются**, а не прикидываются.
|
||||
|
||||
### Чего в коде нет
|
||||
|
||||
```
|
||||
разбиения задачи на части — нет
|
||||
подсчёта токенов для планирования — нет; format_token_count только для показа
|
||||
model_registry.context_window = 128000 — статическое умолчание,
|
||||
а у модели владельца 196608
|
||||
llama-swap — только ttl, групп нет: держать две модели резидентно не станет
|
||||
```
|
||||
|
||||
### Поправка к постановке владельца
|
||||
|
||||
Оркестратор **не переставал** давать задачи локальной модели:
|
||||
|
||||
```
|
||||
local_adapter.classify_error: "timeout", "timed out", "502", "503", "504"
|
||||
→ ErrorCategory.TRANSIENT, retry_delay_seconds=2
|
||||
```
|
||||
|
||||
Профиль не помечается исчерпанным и из цепочки не выбывает. Происходит другое: на каждом запросе локальная модель забирает отведённые Hermes 180 секунд, не успевает, и работу доделывает следующий в цепочке — платный. Чинить надо **подачу работы**, а не возврат в цепочку.
|
||||
|
||||
### Физика, которую нельзя обойти
|
||||
|
||||
**Две модели на одной V100 не работают вдвое быстрее.** Генерация упирается в пропускную способность памяти; две модели делят одну полосу. Вместе они выдадут примерно столько же, сколько одна.
|
||||
|
||||
Значит «параллельно» здесь означает **два независимых решения**, а не выигрыш во времени. Ускорение в интерфейсе обещать нельзя.
|
||||
|
||||
Оговорка: у MoE активны три миллиарда из тридцати, полосу они едят иначе. Два MoE могут ужиться лучше — **это гипотеза, её измеряют, а не закладывают**.
|
||||
|
||||
---
|
||||
|
||||
# Часть 1. Замена моделей и замеры
|
||||
|
||||
## P0-1. Заменить кодер
|
||||
|
||||
Поставить `Qwen3-Coder-30B-A3B-Instruct-Q4_K_M` вместо `Qwen3.8-27B`.
|
||||
|
||||
Основание измерено: **109,6 против 30,3 ток/с**, то же качество 83,3%, и на 4 ГиБ меньше.
|
||||
|
||||
1. Контекст **не ниже 65536** — порог отбора у Hermes.
|
||||
2. Условия запуска взять у нынешнего юнита: `--flash-attn on`, `--cache-type-k/v q8_0`, `--reasoning off`, `--parallel 1`.
|
||||
3. **Прежний юнит сохранить**; откат одной командой описать и проверить.
|
||||
4. Скорость и видеопамять замерить **на живой службе**, а не переносить из A45.
|
||||
|
||||
## P0-2. Заменить компрессор
|
||||
|
||||
Поставить `LFM2.5-2.6B` вместо `Qwen3-4B` на порт 8082.
|
||||
|
||||
1. **Сначала замерить качество сжатия** на том же наборе, что у нынешнего. Быстрее — не значит лучше; сожмёт хуже, замену не делать и так и написать.
|
||||
2. Рассмотреть запуск **на процессоре** (`-ngl 0`): освобождает видеопамять, а сжатие — чтение многого и запись малого, где процессор силён. Замерить оба варианта и дать владельцу числа для решения.
|
||||
|
||||
## P0-3. Измерить кандидатов в пару
|
||||
|
||||
Замерить **расход видеопамяти по процессу при 64К** для `Phi-4-14B`, `Qwen2.5-Coder-14B`, `Qwen3-4B-2507`, `Granite-4.2-8B` — через `nvidia-smi --query-compute-apps=pid,used_memory`, а не по занятости карты.
|
||||
|
||||
Затем **проверить запуском**, какие пары помещаются вместе с компрессором в 32 768 МиБ. Не расчётом.
|
||||
|
||||
Отдельно замерить, **что происходит со скоростью при одновременной работе двух моделей**: суммарная выработка против одиночной. Это проверка утверждения о полосе памяти, и её результат решает, имеет ли смысл держать пару резидентно.
|
||||
|
||||
**Часть 1 сдаётся отдельно.**
|
||||
|
||||
---
|
||||
|
||||
# Часть 2. Надзиратель локальных моделей
|
||||
|
||||
Владелец: «если выбирается локальная модель, должен появляться субагент, который мониторит подачу работы. Если у модели не хватает контекста, он разбивает задачу на куски и подаёт, пока не заработает. Потом формирует память, какой объём давать модели».
|
||||
|
||||
## P0-4. Роль надзирателя
|
||||
|
||||
1. **Новая каноническая роль** `local-supervisor` в реестре.
|
||||
2. **Включается автоматически**, когда выбранный профиль локальный (`local`, `llama.cpp`, `ollama`, `vllm`). Вручную назначать не нужно.
|
||||
3. **Не встаёт между ролью и платным провайдером.**
|
||||
4. **Надзиратель — не модель, а распорядитель.** Считает, режет, подаёт, наблюдает; работу делает локальная модель. Тратить на него платный вызов нельзя.
|
||||
|
||||
Основная его работа — счёт и разбор, а не рассуждение: токены считает `/tokenize`, предел даёт `/props`, границы кусков определяются разбором кода. Ставить сюда слабую модель значит сделать надзирателя менее надёжным.
|
||||
|
||||
## P0-5. Замер перед подачей, а не догадка
|
||||
|
||||
1. **Предел контекста брать у живого сервера** через `/props`. Умолчание `model_registry` = 128000 к модели владельца отношения не имеет.
|
||||
2. **Размер задания считать через `/tokenize`** — точно. Оценка по символам допустима только запасным путём и должна быть помечена как оценка.
|
||||
3. **Учитывать место под ответ**: в контекст входят задание, история и ожидаемый ответ. Запас обосновать.
|
||||
4. **Не выдумывать пределы.** Сервер не ответил — так и записать.
|
||||
|
||||
## P0-6. Разбиение и подача
|
||||
|
||||
1. **Помещается — подавать целиком.** Резать без нужды вредно: теряется связность.
|
||||
2. **Не помещается — резать по смысловым границам**: файл, функция, класс, раздел. Посреди выражения — нельзя.
|
||||
3. **Подавать последовательно**, передавая накопленный результат, и собирать ответ.
|
||||
4. **Неделимая задача — честный отказ**, а не разрез наугад.
|
||||
5. **Число попыток ограничено** и настраивается. Бесконечный цикл на единственной видеокарте недопустим.
|
||||
6. **После исчерпания попыток** — отказ с причиной, дальше обычная отказоустойчивость. Надзиратель **не прячет неудачу**, удерживая работу на локальной модели любой ценой.
|
||||
|
||||
## P0-7. Наблюдение за ходом
|
||||
|
||||
1. **Видеть, что модель работает, а не висит**: поток ответа или тайминги сервера.
|
||||
2. **Различать три исхода**: успел, не успел, ответил ошибкой. Сейчас всё сваливается в «таймаут».
|
||||
3. **Показывать ход** на «Обзоре»: какой кусок из скольких, сколько токенов подано.
|
||||
4. **Отдельно ловить случай A39**: весь лимит ушёл на рассуждения, ответа нет. Измерено: 1500 токенов за 111 секунд и **ноль символов ответа**; с `enable_thinking: false` — ответ за 11 секунд. Признак — пустой ответ при полном расходе лимита; лечится `request_options`, механизм есть после A39.
|
||||
|
||||
## P0-8. Память: какой объём модель тянет
|
||||
|
||||
1. **Записывать по каждой модели**: при каком размере получался ответ, при каком нет, сколько занимало, какой кусок оказался рабочим.
|
||||
2. **Хранить в общей памяти** (`/srv/projects/AI-Memory`, структура после A47). Запись подписывается: модель, когда, по какому заданию.
|
||||
3. **Использовать при следующей подаче**: начинать с размера, который уже работал.
|
||||
4. **Привязывать к имени сборки из метаданных GGUF**, а не к порту или имени профиля. Урок Tiel-Coder: в файле оказалась `Ornith-1.5-35B`.
|
||||
5. **Показывать во вкладке «Память»** надзирателя, что он усвоил.
|
||||
|
||||
---
|
||||
|
||||
# Часть 3. Пара кодеров и облачный судья
|
||||
|
||||
Планировать **по числам части 1**.
|
||||
|
||||
## P0-9. Схема и её цена
|
||||
|
||||
```
|
||||
задание → Кодер A (локальный) ┐
|
||||
→ Кодер B (локальный) ┘→ судья (облачная модель)
|
||||
├ принято → дальше по конвейеру
|
||||
└ не принято → обоим на доработку
|
||||
```
|
||||
|
||||
1. **Кодеры не видят работу друг друга** до суда. Иначе второе решение не независимо и смысл теряется.
|
||||
2. **Судья облачный**, видеопамяти не занимает. Это роль `developer-2` существующего конвейера; модель задаёт владелец в интерфейсе — **зашивать имя модели или провайдера в код нельзя**.
|
||||
3. **Судья получает задание и оба решения**, возвращает: какое принято либо что доработать каждому.
|
||||
4. **Ревьюер остаётся на своём месте** после судьи; конвейер не переделывать.
|
||||
5. **Пара включается настройкой**; возврат к одному кодеру возможен.
|
||||
|
||||
**Предел итераций обязателен.** Круг «пока не сделают правильно» тратит платную квоту судьи на каждом обороте.
|
||||
|
||||
6. **Предел кругов** настраивается, умолчание обосновать. Механизм ограничения итераций в конвейере уже есть — использовать его.
|
||||
7. **Показывать номер круга.**
|
||||
8. **Круги исчерпаны — честный отказ** с последним состоянием обеих работ и мнением судьи. Частичный результат за готовый не выдавать.
|
||||
9. **Считать расход**: сколько вызовов судьи ушло на задачу.
|
||||
10. **Круг без изменений — застревание.** Оба вернули то же, что и в прошлый раз — прекратить и сказать.
|
||||
|
||||
---
|
||||
|
||||
## P0-10. Аудит вторым проходом
|
||||
|
||||
1. **Числа части 1 сняты на живой службе**, а не перенесены из A45.
|
||||
2. **Откат к прежнему кодеру** выполнен и проверен.
|
||||
3. **Пара проверена запуском**, а не расчётом: обе модели подняты, памяти хватило, обе отвечают.
|
||||
4. **Утверждение о полосе памяти** проверено: суммарная выработка двух моделей против одиночной. Результат записать, каким бы он ни был.
|
||||
5. **Заведомо большая задача** разбита, подана и собрана; **неделимая** дала честный отказ.
|
||||
6. **Предел попыток и предел кругов** проверены задачей, которая не выполнится никогда.
|
||||
7. **Предел контекста взят у сервера**, счёт токенов сверен с `/tokenize` независимо.
|
||||
8. **Независимость кодеров**: решение одного не попадает в контекст другого.
|
||||
9. **Модель судьи задаётся из интерфейса**, а не зашита.
|
||||
10. **Для платных провайдеров путь не изменился**, надзиратель туда не лезет.
|
||||
11. **Службы владельца вернуть в рабочее состояние.**
|
||||
12. **Побочные изменения** объяснить.
|
||||
13. **Пропущенный пункт назвать пропущенным.**
|
||||
|
||||
---
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Видеокарта одна, сервер рабочий: окна для замеров согласовать, службы возвращать в строй.
|
||||
- Прежние юниты сохранять, откат описывать и проверять.
|
||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
||||
- Имена моделей и провайдеров в код не зашивать.
|
||||
- Конфигурацию Hermes не править.
|
||||
- Версию `0.1.1` не поднимать.
|
||||
- Правило честности без исключений: ни одного числа без замера; ускорение не обещать без подтверждения.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
**Часть 1**
|
||||
|
||||
1. Кодер заменён на Qwen3-Coder-30B-A3B, контекст не ниже 65536; скорость и видеопамять замерены на живой службе; откат проверен.
|
||||
2. Компрессор замерен в обоих вариантах; замена сделана либо обоснованно отклонена.
|
||||
3. Видеопамять четырёх кандидатов при 64К измерена по процессу.
|
||||
4. Проверено запуском, какие пары помещаются; измерено, что со скоростью при одновременной работе.
|
||||
|
||||
**Часть 2**
|
||||
|
||||
5. Роль `local-supervisor` включается автоматически для локальных профилей; для остальных путь не изменился.
|
||||
6. Предел контекста берётся через `/props`; размер задания считается через `/tokenize`; проверено.
|
||||
7. Большая задача разбивается по смысловым границам и собирается; неделимая даёт отказ.
|
||||
8. Число попыток ограничено; бесконечного цикла нет.
|
||||
9. Ход виден владельцу; случай «весь лимит на рассуждения» распознаётся отдельно от таймаута.
|
||||
10. Рабочий объём записан в общую память с привязкой к имени сборки GGUF и используется при следующей подаче.
|
||||
|
||||
**Часть 3**
|
||||
|
||||
11. Пара работает независимо; облачный судья сравнивает и возвращает на доработку.
|
||||
12. Модель судьи задаётся из интерфейса.
|
||||
13. Предел кругов работает; застревание распознаётся; расход вызовов судьи показан.
|
||||
14. Пара включается и отключается настройкой.
|
||||
|
||||
**Общее**
|
||||
|
||||
15. Неизмеренное показано как `Н/Д` с причиной; неудачи не скрываются.
|
||||
16. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **517**.
|
||||
17. Службы владельца работают.
|
||||
18. Память проекта в AI-Memory обновлена.
|
||||
19. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
|
||||
|
||||
## Главное
|
||||
|
||||
Сейчас локальная модель получает задачу целиком, не успевает за отведённое время и отдаёт работу платному провайдеру. Так на каждом запросе: она не выбывает из цепочки, она просто всякий раз проигрывает.
|
||||
|
||||
Замена кодера окупается сама по себе — вчетверо быстрее при том же качестве и на четыре гигабайта меньше. Надзиратель делает подачу работы соразмерной модели: измеряет, а не предполагает, режет по смыслу и запоминает рабочий объём. Пара кодеров с облачным судьёй добавляет вторую независимую попытку — но её ценность в разных ошибках, а не в скорости, и каждый круг доработки стоит платного вызова.
|
||||
|
||||
## Порядок сдачи
|
||||
Передать точный `FINAL_COMMIT_SHA`.
|
||||
122
agents/inbox/2026-08-31-A55-account-connection-remaining.md
Normal file
122
agents/inbox/2026-08-31-A55-account-connection-remaining.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
# Задание A55: оставшиеся дефекты подключения аккаунтов
|
||||
|
||||
## Дата поступления
|
||||
2026-08-31
|
||||
|
||||
## База
|
||||
|
||||
`origin/main` (`26f7d2c`).
|
||||
|
||||
```
|
||||
git fetch origin --prune
|
||||
git checkout -b antigravity/a55-account-connection origin/main
|
||||
```
|
||||
|
||||
В `main` напрямую не пушить.
|
||||
|
||||
## Порядок исполнения
|
||||
|
||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан для аудитора.
|
||||
|
||||
Владелец не может настроить ни одного аккаунта. Это блокирует всю работу с хабом.
|
||||
|
||||
---
|
||||
|
||||
## Что ревьюер уже починил — не переделывать
|
||||
|
||||
В `main` закрыто и проверено исполнением:
|
||||
|
||||
```
|
||||
кэш опознания _identities/_snapshots не чистились при удалении ключа:
|
||||
слот, переиспользованный под другой аккаунт, показывал
|
||||
прежнюю почту. Добавлен forget_profile.
|
||||
NVIDIA успешный список моделей теперь считается доказательством
|
||||
рабочего ключа; отказ пробного запроса («Function ... Not
|
||||
found for account») больше не валит подключение.
|
||||
401 и 403 по-прежнему отказ.
|
||||
выход из программы неудачная остановка процессов больше не отменяет выход
|
||||
конфигурация Hermes проверяется семь известных путей, в сообщении
|
||||
перечисляется, где искали
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P0-1. Antigravity не подключается на Windows
|
||||
|
||||
Владелец: «на винде не подключается аккаунт аги, выдаёт ошибку API, хотя в браузере вышло, что авторизация прошла. Скорее всего требует ссылку с браузера, как в линуксе».
|
||||
|
||||
В браузере открывается `127.0.0.1:<порт>` и показывается «Авторизация успешно завершена», но мастер этого не видит и завершает шаг 3 с «Не указан API-ключ или не завершена авторизация».
|
||||
|
||||
1. **Разобраться, почему успешный возврат не доходит до мастера** на Windows, тогда как на Linux доходит.
|
||||
2. **Мастер обязан дождаться** завершения входа и увидеть его результат, а не требовать ключ у провайдера, который работает по ссылке.
|
||||
3. **Если возврат по ссылке на Windows невозможен** — дать тот же путь, что на Linux: поле для вставки ссылки или кода. Владелец сам это предположил.
|
||||
4. **Сообщение «Не указан API-ключ» для Antigravity неверно по сути**: у него ключа нет, у него вход по ссылке. Текст должен соответствовать способу подключения.
|
||||
|
||||
## P0-2. Ollama ищет сервер не там
|
||||
|
||||
Ошибка `WinError 10061` честна, но бесполезна: мастер по умолчанию подставляет `http://127.0.0.1:11434/v1`, то есть машину, где запущен хаб. Ollama владельца работает **на сервере**.
|
||||
|
||||
1. **Подсказка должна объяснять**, что адрес относится к машине с хабом, и предлагать указать сетевой адрес сервера.
|
||||
2. **Кнопка «Найти на этом компьютере»** уже есть — добавить проверку заданного вручную адреса с внятным ответом.
|
||||
3. **Суффикс `/v1` для Ollama лишний**: нативный интерфейс живёт на `/api`. Проверить, какой адрес подставляется по умолчанию и куда потом идут запросы.
|
||||
|
||||
## P0-3. Antigravity на Linux: подключился, моделей нет
|
||||
|
||||
Аккаунт подключён, но «Список моделей ещё не получен», «Каталог моделей (0)», состояние «Не проверялось». При этом квоты подтянулись и показывают 100% — значит связь с провайдером есть.
|
||||
|
||||
1. **Выяснить, почему квоты приходят, а список моделей нет.** Источники разные, и один работает.
|
||||
2. Возможно, поможет уже сделанный сброс кэша — **проверить на живой установке владельца** до того, как чинить что-то ещё.
|
||||
|
||||
## P0-4. Версия в интерфейсе
|
||||
|
||||
После правки ревьюера номер версии берётся из API, а зашитые значения из разметки убраны. На сборке `b2ca7cd` владелец всё ещё видит `Hermes Hub Web v0.1.1` при версии `0.1.2`.
|
||||
|
||||
1. **Проверить, что API отдаёт версию** и что клиент её получает на всех экранах, а не только при открытии панели обновления.
|
||||
2. **Не подставлять значение по умолчанию.** Нет версии — писать `Н/Д` с причиной.
|
||||
|
||||
## P0-5. Экран настроек пуст
|
||||
|
||||
На «Настройках» половина полей не заполнена: «Н/Д: нет в снапшоте», «Н/Д: API не передаёт путь», «Н/Д: текущее значение не передано». Пустуют хост и порт, токен, порог квоты, маскирование почты, каталоги данных и конфигурации, путь к журналу.
|
||||
|
||||
1. **Передавать текущие значения настроек** в снапшот, чтобы поля показывали настроенное, а не заглушку.
|
||||
2. **Токен не показывать целиком** — достаточно признака «задан» и возможности заменить.
|
||||
3. **Значение действительно неизвестно — оставить `Н/Д` с причиной.** Заполнять правдоподобным нельзя.
|
||||
|
||||
## P0-6. Аудит вторым проходом
|
||||
|
||||
1. **Пройти путь подключения целиком на обеих машинах**: Antigravity, NVIDIA, OpenRouter, Ollama. Скриншоты приложить.
|
||||
2. **Проверить, что после смены аккаунта в слоте показывается новая почта** — правка ревьюера, убедиться, что она работает на живой установке.
|
||||
3. **Проверить сообщение о конфигурации Hermes**: оно должно перечислять проверенные пути.
|
||||
4. **Различать «нет доступа» и «не найдено»** — не повторять ошибку ложного диагноза.
|
||||
5. **Побочные изменения** объяснить.
|
||||
6. **Пропущенный пункт назвать пропущенным.**
|
||||
|
||||
---
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
||||
- Правки ревьюера из `main` не откатывать.
|
||||
- Версию `0.1.2` не понижать.
|
||||
- Правило честности без исключений: причина отказа доходит до владельца текстом, неизвестное показывается как `Н/Д` с причиной.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Ветка в `origin`, `git status` чист.
|
||||
2. Antigravity подключается на Windows; путь входа проверен вручную, скриншоты приложены.
|
||||
3. Текст ошибки соответствует способу подключения провайдера.
|
||||
4. Ollama: подсказка объясняет, чья это машина; заданный вручную адрес проверяется; суффикс пути верный.
|
||||
5. Antigravity на Linux отдаёт список моделей; причина прежнего отказа названа.
|
||||
6. Версия в интерфейсе совпадает с установленной на всех экранах.
|
||||
7. Поля настроек показывают текущие значения; неизвестное помечено `Н/Д` с причиной.
|
||||
8. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **599**.
|
||||
9. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
|
||||
|
||||
## Главное
|
||||
|
||||
Владелец третий день не может подключить ни одного аккаунта. Часть причин уже устранена — подменённая почта из кэша, ложный отказ NVIDIA, отмена выхода из программы. Осталось четыре: вход Antigravity на Windows, адрес Ollama, отсутствие моделей на Linux и незаполненные настройки.
|
||||
|
||||
Каждая проверяется вручную на живой установке. Тесты все эти дефекты пропустили.
|
||||
|
||||
## Порядок сдачи
|
||||
Передать точный `FINAL_COMMIT_SHA`.
|
||||
141
agents/inbox/2026-09-01-A56-context-compression.md
Normal file
141
agents/inbox/2026-09-01-A56-context-compression.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
# Задание A56: сжатие контекста — компрессор должен начать работать
|
||||
|
||||
## Дата поступления
|
||||
2026-09-01
|
||||
|
||||
## База
|
||||
|
||||
`origin/main` (`26f7d2c`).
|
||||
|
||||
```
|
||||
git fetch origin --prune
|
||||
git checkout -b antigravity/a56-context-compression origin/main
|
||||
```
|
||||
|
||||
В `main` напрямую не пушить.
|
||||
|
||||
## Порядок исполнения
|
||||
|
||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан для аудитора.
|
||||
|
||||
Зона: надзиратель локальных моделей и локальный адаптер. С A55 (подключение аккаунтов) не пересекается.
|
||||
|
||||
---
|
||||
|
||||
## Задача
|
||||
|
||||
На сервере владельца работает вторая локальная модель, называемая компрессором. Ревьюер проверил код: **в хабе нет ни одной строки, которая бы к ней обращалась для сжатия**. Порт 8082 упоминается ровно один раз — в списке адресов для обнаружения локальных серверов.
|
||||
|
||||
Надзиратель из A52 умеет резать задачу на куски по смысловым границам, и это работает. Но накопленный контекст между кусками никто не сжимает, и модель простаивает.
|
||||
|
||||
## Что проверено ревьюером на живом сервере — заново не мерить
|
||||
|
||||
Настройка после переделки раскладки:
|
||||
|
||||
```
|
||||
кодер Qwen3-Coder-30B-A3B порт 8081 -c 229376 30 008 МиБ 107,4 ток/с
|
||||
компрессор Qwen3-4B-2507 порт 8082 -c 32768 на CPU, -ngl 0 -t 32
|
||||
604 МиБ видеопамяти
|
||||
свободно на карте: 2 152 МиБ из 32 768
|
||||
```
|
||||
|
||||
**Скорость компрессора на процессоре измерена на настоящем промпте:**
|
||||
|
||||
```
|
||||
промпт 5068 токенов → 853,9 ток/с
|
||||
генерация → 5,4 ток/с
|
||||
```
|
||||
|
||||
То есть сжать 32 тысячи токенов — около 38 секунд чтения плюс несколько секунд на сводку. Чтение быстрое, генерация медленная; для сжатия это удачное сочетание, потому что на выходе короткий текст.
|
||||
|
||||
**Качество сжатия у этой модели замерено в A52: 100%** — сохраняет порты, адреса, контрольные суммы. Проверено ревьюером повторно: в ответе остались и адрес сервера, и оба порта, и имя модели.
|
||||
|
||||
**Родной контекст кодера — 262 144** по метаданным GGUF, поэтому 224К внутри предела.
|
||||
|
||||
**Обёртка над llama-server удалена**, юниты описывают действительность. Подменять бинарник больше нельзя: настройки задаются юнитом.
|
||||
|
||||
---
|
||||
|
||||
## P0-1. Компрессор становится настраиваемой ролью
|
||||
|
||||
1. **Отдельная роль или настройка профиля** — «модель для сжатия контекста». Владелец выбирает её в интерфейсе из подключённых локальных профилей.
|
||||
2. **Адрес берётся из профиля**, а не зашивается. Порт 8082 сегодняшний, завтра другой.
|
||||
3. **Компрессор не участвует в маршрутизации Hermes.** Это служебная роль: она не должна попадать в цепочки ролей и не обязана проходить порог в 64К. Если владелец захочет — назначит её явно, но по умолчанию нет.
|
||||
4. **Компрессор не настроен — сжатие не выполняется**, и это нормальное состояние. Показывать `Н/Д: модель для сжатия не выбрана`, а не ошибку.
|
||||
|
||||
## P0-2. Когда сжимать
|
||||
|
||||
1. **Порог по заполнению контекста**, а не по числу сообщений. Предел берётся у сервера через `/props`, размер накопленного — через `/tokenize`; оба механизма уже есть в надзирателе после A52.
|
||||
2. **Значение порога настраивается**, умолчание обосновать. Разумно начинать сжатие, когда занято около трёх четвертей.
|
||||
3. **Сжимать самое старое**, оставляя свежее нетронутым: последние сообщения нужны модели дословно.
|
||||
4. **Не сжимать то, что уже сжато.** Повторное сжатие сводки теряет факты и делает это незаметно.
|
||||
|
||||
## P0-3. Что сохранять обязательно
|
||||
|
||||
Главное требование к качеству, и оно проверяемое.
|
||||
|
||||
Сводка обязана сохранять **дословно**: пути к файлам, адреса и порты, имена функций и переменных, контрольные суммы, номера версий и коммитов, точные значения из замеров.
|
||||
|
||||
1. **Проверять это тестом**: подать текст с известными значениями и убедиться, что они в сводке остались.
|
||||
2. **Потеря факта — дефект**, а не приемлемая цена сжатия. Модель на этой задаче даёт 100%, значит планка достижима.
|
||||
3. **Указывать степень сжатия**: было столько токенов, стало столько.
|
||||
|
||||
## P0-4. Видно, что происходит
|
||||
|
||||
1. **Показывать факт сжатия** владельцу: когда, сколько токенов было и стало, сколько заняло.
|
||||
2. **Хранить исходный текст** до конца задачи, чтобы можно было вернуться, если сводка потеряла нужное.
|
||||
3. **Сжатие не должно идти молча**: 38 секунд тишины владелец воспримет как зависание.
|
||||
4. **Ошибка сжатия не роняет задачу.** Компрессор не ответил — работаем с несжатым контекстом и говорим об этом, а не прекращаем работу.
|
||||
|
||||
## P0-5. Память о том, что сработало
|
||||
|
||||
Продолжение линии A52.
|
||||
|
||||
1. Записывать в общую память (`/srv/projects/AI-Memory`): какой объём сжимался, во сколько раз, сколько заняло, сохранились ли факты.
|
||||
2. Привязывать к **имени сборки GGUF**, а не к порту или имени профиля.
|
||||
3. Использовать накопленное: начинать с размера куска, который уже давал хороший результат.
|
||||
|
||||
## P0-6. Аудит вторым проходом
|
||||
|
||||
1. **Проверить на живом сервере**, а не заглушкой: подать текст больше порога и убедиться, что сжатие произошло и факты уцелели.
|
||||
2. **Проверить сохранение дословных значений** — пути, порты, суммы. Это главный критерий.
|
||||
3. **Проверить, что компрессор не попал в маршрутизацию** Hermes и не мешает выбору моделей.
|
||||
4. **Проверить поведение при недоступном компрессоре**: задача продолжается на несжатом контексте.
|
||||
5. **Убедиться, что предел контекста и счёт токенов берутся у сервера**, а не из умолчаний.
|
||||
6. **Побочные изменения** объяснить.
|
||||
7. **Пропущенный пункт назвать пропущенным.**
|
||||
|
||||
---
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Юниты владельца не править: обёртку над `llama-server` только что убрали, подменять бинарник запрещено.
|
||||
- Службы `qwen-coder` и `qwen-compressor` возвращать в рабочее состояние после проверок.
|
||||
- Адреса и порты в код не зашивать.
|
||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
||||
- Версию `0.1.2` не понижать.
|
||||
- Правило честности без исключений: неизмеренное — `Н/Д` с причиной, потерянный факт — дефект.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Ветка в `origin`, `git status` чист.
|
||||
2. Модель для сжатия выбирается в интерфейсе; адрес берётся из профиля.
|
||||
3. Компрессор не участвует в маршрутизации Hermes по умолчанию.
|
||||
4. Порог сжатия считается от предела контекста, взятого через `/props`, и объёма, посчитанного через `/tokenize`.
|
||||
5. Сжимается старое, свежее остаётся дословным; повторное сжатие сводки не выполняется.
|
||||
6. Тест на сохранение дословных значений проходит: пути, порты, контрольные суммы, номера версий.
|
||||
7. Владелец видит факт и степень сжатия; исходный текст сохраняется до конца задачи.
|
||||
8. Недоступный компрессор не роняет задачу.
|
||||
9. Опыт записан в общую память с привязкой к имени сборки GGUF.
|
||||
10. Проверено на живом сервере, вывод приложен.
|
||||
11. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше **599**.
|
||||
12. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
|
||||
|
||||
## Главное
|
||||
|
||||
Владелец держит на сервере вторую модель под сжатие контекста, освободил ради неё место и вынес её на процессор. Модель работает, отвечает и сжимает правильно — но в хабе нет кода, который бы её позвал.
|
||||
|
||||
Задание закрывает разрыв между настроенным железом и неиспользуемой возможностью. Ключевое требование одно: **сводка не теряет фактов**. Модель на этой задаче даёт сто процентов, значит планка достижима, и снижать её нельзя — потерянный путь или порт всплывёт через два шага в виде необъяснимой ошибки.
|
||||
|
||||
## Порядок сдачи
|
||||
Передать точный `FINAL_COMMIT_SHA`.
|
||||
232
agents/inbox/2026-09-02-A60-update-completes.md
Normal file
232
agents/inbox/2026-09-02-A60-update-completes.md
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
# Задание A60: обновление доводит себя до конца
|
||||
|
||||
## Дата поступления
|
||||
2026-09-02
|
||||
|
||||
## База
|
||||
|
||||
`origin/main` (`2f35377`). A59 влит: ветка `antigravity/a59-visible-update`
|
||||
принята с исправлениями ревьюера (`285ae7c`). Показ и загрузку переписывать
|
||||
второй раз не надо.
|
||||
|
||||
```
|
||||
git fetch origin --prune
|
||||
git checkout -b antigravity/a60-update-completes origin/main
|
||||
```
|
||||
|
||||
В `main` напрямую не пушить.
|
||||
|
||||
## Порядок исполнения
|
||||
|
||||
Два прохода: **Flash** реализует, **Pro** проводит аудит. Пункт **P0-6** написан
|
||||
для аудитора.
|
||||
|
||||
Зона: применение обновления и перезапуск. Показа и загрузки не касается — там всё
|
||||
принято и проверено.
|
||||
|
||||
---
|
||||
|
||||
## Что уже сделано — переделывать не надо
|
||||
|
||||
A59 довёл обновление до экрана. Ревьюер проверил и принял:
|
||||
|
||||
```
|
||||
окно при запуске, отказ по версии, молчание без обновлений работает
|
||||
полоса хода, честное Н/Д без размера работает
|
||||
отмена с удалением недокачанного файла работает
|
||||
отмена принимается только на проверке и загрузке работает
|
||||
SHA-256 обязательна, непроверенный файл не запускается работает
|
||||
запись о применённом обновлении только после успеха работает
|
||||
```
|
||||
|
||||
Ничего из этого не трогать.
|
||||
|
||||
---
|
||||
|
||||
## Задача
|
||||
|
||||
Владелец нажимает «Обновить сейчас». Пакет скачивается на глазах, сумма сходится,
|
||||
начинается установка — и на этом всё кончается. Хаб не поднимается, окно гаснет,
|
||||
владелец идёт ставить сборку руками. Ровно то, из-за чего писалось A59.
|
||||
|
||||
---
|
||||
|
||||
## Разрыв, и он один на обеих системах
|
||||
|
||||
**Установщик снимает тот процесс, который его запустил и ждёт.**
|
||||
|
||||
`install_latest_update` делает так:
|
||||
|
||||
```
|
||||
stop_running_hub() свои процессы, кроме текущего
|
||||
subprocess.run(["bash", installer], timeout=600) ЖДЁТ здесь
|
||||
schedule_restart() сюда управление не доходит
|
||||
```
|
||||
|
||||
**Linux — проверено исполнением, цепочка целиком.**
|
||||
|
||||
```
|
||||
1. хаб: subprocess.run(["bash", installer], capture_output=True)
|
||||
stdout установщика — труба, единственный читатель которой сам хаб
|
||||
2. install-linux.sh, шаг [0/6]: stop_running_hub снимает хаб
|
||||
pgrep -u $(id -u) -f "antigravity_provider.router.web|hermes_hub_web_entry"
|
||||
никого не исключает, под шаблон попадает тот, кто запустил установщик
|
||||
3. хаб мёртв -> у трубы не осталось читателя
|
||||
4. следующий echo установщика -> SIGPIPE -> установщик умирает на шаге [1/6]
|
||||
5. не установлено ничего; перезапускать нечего
|
||||
```
|
||||
|
||||
**Установщик не доживает до конца — он умирает раньше, чем что-либо поставит.**
|
||||
Это не «поставилось, но не запустилось»: манифест и код остаются на прежней
|
||||
сборке. Проверено контрольным опытом — тот же скрипт, та же смерть родителя: с
|
||||
выводом в файл доходит до конца, с `capture_output=True` умирает.
|
||||
|
||||
Отсюда следует, что одной перестановкой `schedule_restart` делу не помочь: пока
|
||||
установщик пишет в трубу убитого им процесса, он не доживёт до установки при
|
||||
любом порядке вызовов.
|
||||
|
||||
**Windows — прочитано по коду, живьём не проверялось.**
|
||||
`installer/HermesHubSetup.cs:280` (`StopOwnedRuntime`) выглядит аккуратнее: строит `$protected` — цепочку собственных предков, чтобы не снять
|
||||
того, кто его запустил. Но проверка `-notin $protected` стоит **только на
|
||||
дочерних** процессах внутри `Stop-HubBranch`. Сам процесс-цель снимается
|
||||
безусловно, а хаб под шаблон `antigravity_provider\.router\.web` подходит.
|
||||
Труба там та же: `proc.wait(timeout=600)` при `Popen` без перенаправления вывода.
|
||||
**Подтвердить исполнением, а не поверить на слово.**
|
||||
|
||||
Отката на путях установщика нет вообще: он есть только для `.zip`
|
||||
(`apply_update_sync`). Ни `.sh`, ни `.exe` при срыве на середине ничего не
|
||||
возвращают.
|
||||
|
||||
---
|
||||
|
||||
## P0-1. Отсоединённый помощник
|
||||
|
||||
Порядок не выдумывать заново — он описан в docstring `schedule_restart`: сначала
|
||||
отсоединённый помощник, потом выход текущего процесса. Лаунчер считает хаб
|
||||
работающим, пока порт отвечает, поэтому поднимать новый, не освободив порт,
|
||||
бесполезно.
|
||||
|
||||
1. **Хаб порождает помощника** (`setsid` или отдельная группа процессов на Linux,
|
||||
`DETACHED_PROCESS` на Windows), передаёт ему путь к уже проверенному пакету и
|
||||
**выходит сам**, освободив порт.
|
||||
2. **Помощник**: дожидается освобождения порта → запускает установщик → поднимает
|
||||
хаб → завершается.
|
||||
3. **Ни одна труба помощника не должна вести в хаб.** Это то самое место, где всё
|
||||
ломается сейчас: `capture_output=True` делает читателем вывода тот процесс,
|
||||
который установщик собирается снять. Вывод установщика — сразу в файл, а не в
|
||||
`PIPE`, и не через процесс, которому предстоит умереть.
|
||||
4. **Помощник не наследует** ни stdout хаба, ни его рабочий каталог: хаб исчезнет
|
||||
раньше, чем помощник закончит.
|
||||
5. **Помощник пишет свой ход в файл** `~/.hermes/updates/apply-<время>.log`, чтобы
|
||||
после неудачи было что показать. Пустой отказ без причины — уже было в A59,
|
||||
второй раз не проходит.
|
||||
|
||||
## P0-2. Владелец видит, что происходит
|
||||
|
||||
1. **Перед выходом статус `restarting`** с текстом, что хаб сейчас закроется и
|
||||
поднимется сам. Не «установлено» — установка ещё идёт.
|
||||
2. **Интерфейс переживает разрыв.** Опрос `get_update_progress` получит отказ
|
||||
соединения: это ожидаемое состояние, а не ошибка. Показывать «Hermes Hub
|
||||
перезапускается», продолжать пробовать, при возврате — перечитать страницу.
|
||||
3. **Не молчать бесконечно.** Не поднялся за отведённое время — сказать это прямо
|
||||
и назвать путь к журналу помощника.
|
||||
|
||||
## P0-3. Откат на путях установщика
|
||||
|
||||
1. **Помощник снимает копию установленного** до запуска установщика.
|
||||
2. **Установщик вернул не ноль или хаб не поднялся** за отведённое время — вернуть
|
||||
прежнее и поднять его.
|
||||
3. **Причина отказа** — с кодом возврата и хвостом вывода — в журнал и на экран
|
||||
при следующем старте.
|
||||
4. **Программа обязана остаться работоспособной.** Это главное требование пункта:
|
||||
неудачное обновление не имеет права оставить владельца без хаба.
|
||||
|
||||
## P0-4. Проверка исполнением
|
||||
|
||||
Тестами это не ловится: дело в живом переходе между версиями и в том, кто кого
|
||||
снимает.
|
||||
|
||||
1. **Поставить `v0.1.2-b7`, обновиться на `v0.1.3-b1` через интерфейс.** Оба
|
||||
релиза опубликованы, установщики и `checksums.txt` на месте. Скриншоты: окно,
|
||||
полоса, экран после возврата.
|
||||
2. **Замерить время** от нажатия до готовности.
|
||||
3. **Проверить, что работает новый код** — по `running_commit`, снятому при старте
|
||||
процесса, а не по номеру версии.
|
||||
4. **Повторить на Windows.**
|
||||
5. **Сорвать установку намеренно** (испорченный установщик) — проверить откат и
|
||||
что хаб жив.
|
||||
6. **Проверить, что старый процесс не остался** и порт занят новым.
|
||||
|
||||
## P0-5. Обновление вообще предлагается
|
||||
|
||||
Найдено тем же прогоном, до того как дело дошло до установки.
|
||||
|
||||
Хаб на `v0.1.2-b7` при живом релизе `v0.1.3-b1` ответил:
|
||||
`«Установлена сборка новее опубликованного релиза (4fa9939 от 2026-09-02)»`,
|
||||
`update_available: false`. Обновиться было нельзя вообще.
|
||||
|
||||
Причина: `deployed_at` в `deployment_manifest.json` пишется установщиком в момент
|
||||
**установки**, а не сборки, и сравнивается с `published_at` релиза. Поставил
|
||||
старую сборку сегодня — она «новее» любого релиза, и обновление не предложат
|
||||
больше никогда. Владелец, переставивший сборку руками, выпадает из обновлений
|
||||
молча.
|
||||
|
||||
1. **Сравнивать сборки, а не дату установки.** Дата установки не говорит о том,
|
||||
какой код внутри.
|
||||
2. **Если сравнить нечем — предложить обновление, а не промолчать.** Молчание
|
||||
здесь дороже лишнего окна: владелец не узнает, что отстал.
|
||||
3. **Причину решения показывать.** «Установлена сборка новее релиза» — вывод, а
|
||||
не факт; рядом должно стоять, из чего он сделан.
|
||||
|
||||
## P0-6. Аудит вторым проходом
|
||||
|
||||
1. **Пройти обновление целиком на обеих системах.**
|
||||
2. **Проверить, что помощник не снимает чужого** — только процессы хаба своего
|
||||
пользователя.
|
||||
3. **Проверить откат** при сорвавшейся установке и что после него хаб отвечает.
|
||||
4. **Проверить, что интерфейс не объявляет успех раньше времени** — ни на выходе
|
||||
хаба, ни на разрыве связи.
|
||||
5. **Побочные изменения** объяснить.
|
||||
6. **Пропущенный пункт назвать пропущенным.** В A59 живая проверка была пропущена
|
||||
молча, при том что релиз для неё был опубликован за девять часов до сдачи.
|
||||
|
||||
---
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Показ и загрузку из A59 не переделывать.
|
||||
- Проверку SHA-256 и список разрешённых адресов не ослаблять.
|
||||
- Показ работающего коммита и времени запуска не ломать.
|
||||
- Фронтенд без npm, без сборки, без фреймворков — по `docs/web-api/CONTRACT.md` §1.
|
||||
- **Правки ревьюера из `main` не откатывать — включая комментарии.** В A59 сняли
|
||||
шесть блоков с объяснением прошлых регрессий, ревьюер возвращал их руками.
|
||||
- Версию `0.1.3` не понижать.
|
||||
- Правило честности без исключений: неизвестное — `Н/Д` с причиной, а не
|
||||
правдоподобное число и не полоса во всю ширину.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Ветка в `origin`, `git status` чист.
|
||||
2. Обновление, запущенное из интерфейса, доходит до работающего нового хаба **без
|
||||
участия владельца** — на Linux и на Windows, подтверждено скриншотами.
|
||||
3. После перезапуска `running_commit` соответствует новой сборке.
|
||||
4. Сорвавшаяся установка откатывается, хаб остаётся работоспособным.
|
||||
5. Старый процесс не остался, порт занят новым.
|
||||
6. Журнал помощника пишется, и при отказе на него указывают.
|
||||
7. Обновление предлагается по сравнению сборок, а не по дате установки;
|
||||
переустановка старой сборки не выключает обновления навсегда.
|
||||
8. `ruff check .` чисто; релизный гейт пройден; тестов не меньше **740**.
|
||||
9. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`,
|
||||
`X passed / Y skipped / Z failed`, тайминги, скриншоты.
|
||||
|
||||
## Главное
|
||||
|
||||
A59 сделал обновление видимым: владелец видит окно и видит загрузку. Дальше
|
||||
механизм обрывается на самом простом — установщик снимает того, кто его запустил
|
||||
и ждёт результата.
|
||||
|
||||
Задание про один шаг: чтобы после нажатия «Обновить сейчас» владелец больше
|
||||
ничего не делал.
|
||||
|
||||
## Порядок сдачи
|
||||
Передать точный `FINAL_COMMIT_SHA`.
|
||||
150
agents/inbox/2026-09-02-HUB1-audit-p0-green-main.md
Normal file
150
agents/inbox/2026-09-02-HUB1-audit-p0-green-main.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# Задание HUB-1: зелёный main и P0 из аудита Hermes Hub
|
||||
|
||||
## Для кого
|
||||
**Серверная сессия Claude (пользователь `ochenstarik`), не для agy.** Это работа
|
||||
исполнителя-Claude: правка кода, прогон, пуш. Ревьюер (сессия на ПК) принимает.
|
||||
|
||||
## Дата
|
||||
2026-09-02.
|
||||
|
||||
## База
|
||||
`origin/main` (`144f6a5`).
|
||||
|
||||
```
|
||||
git fetch origin --prune
|
||||
git checkout -b hub/audit-p0-green-main origin/main
|
||||
```
|
||||
|
||||
В `main` напрямую не пушить. **Координация:** над `main` работают две сессии.
|
||||
Перед пушем — `git fetch` и `git log --oneline origin/main`; при расхождении
|
||||
перенести правки поверх, как это уже делалось.
|
||||
|
||||
## Зачем
|
||||
|
||||
По решению о слиянии (`docs/research/kagent-merge-decision.md`) шаг 1 —
|
||||
**Hermes довести до зелёного и стабильного**, потому что он служит эталоном
|
||||
переноса, а сломанный эталон портировать нельзя. Сейчас `main` красный. Полный
|
||||
аудит — на рабочем столе владельца (`HERMES_HUB_FULL_AUDIT_2026-09-02.md`);
|
||||
здесь только то, что подтверждено исполнением.
|
||||
|
||||
---
|
||||
|
||||
## Что ревьюер уже проверил — заново не выяснять
|
||||
|
||||
### CI на main красный. Причина — два дефекта, оба видны в логе последнего прогона
|
||||
|
||||
**1. Security-инвариант A37 не держится на Windows.**
|
||||
`tests/test_a37_isolation_guards.py:394` падает:
|
||||
|
||||
```
|
||||
AssertionError: команда со стильдой прошла мимо защиты: rm -rf $HOME/.hermes (OK)
|
||||
assert not True
|
||||
```
|
||||
|
||||
WorkspaceBoundaryGuard пропускает разрушительную команду с `$HOME`, потому что
|
||||
раскрытие переменных и нормализация путей на Windows и Linux различаются. Это
|
||||
не косметика — это граница вокруг агентских shell-действий. Пока она работает
|
||||
по-разному на поддерживаемых системах, sandbox нельзя считать доказанным.
|
||||
|
||||
**2. Windows UTF-8 роняет verification-скрипт.**
|
||||
`scripts/verify_multi_provider_router.py:63`:
|
||||
|
||||
```
|
||||
UnicodeEncodeError: 'charmap' codec can't encode characters ...
|
||||
```
|
||||
|
||||
Скрипт печатает русский текст (`[PASS] Чистая конфигурация...`), а консоль
|
||||
Windows в CI — cp1252. Падает `print`, не логика.
|
||||
|
||||
Красные джобы: `Headless Run (no GUI dependencies)` и
|
||||
`Clean Windows Runner Test`.
|
||||
|
||||
### Баг pricing fallback (P2, но реальный)
|
||||
|
||||
`src/antigravity_provider/router/telemetry_service.py:164`:
|
||||
|
||||
```python
|
||||
data = yaml.safe_dump(p.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict) and "pricing" in data: # всегда False
|
||||
```
|
||||
|
||||
`safe_dump` вместо `safe_load` — таблица цен из `pricing.yaml` не грузится
|
||||
никогда, и `except: pass` это глушит. Должно быть `safe_load`.
|
||||
|
||||
---
|
||||
|
||||
## P0-1. Зелёный main (это первично)
|
||||
|
||||
1. **Исправить UTF-8 в verification-скрипте**: принудительный UTF-8 вывода
|
||||
(`PYTHONUTF8`, `PYTHONIOENCODING=utf-8`, реконфигурация `sys.stdout`, либо
|
||||
безопасное кодирование). Кросс-платформенно, проверяемо на обеих системах.
|
||||
2. **Исправить WorkspaceBoundaryGuard** единым конвейером: классификация
|
||||
диалекта shell → раскрытие только распознанных переменных → нормализация
|
||||
разделителей → разрешение `$HOME`/`%USERPROFILE%` → канонизация пути →
|
||||
сравнение с защищёнными корнями → **fail closed**. Одинаковый тест-набор для
|
||||
Windows и Linux; `test_a37_isolation_guards` должен ловить `rm -rf $HOME/...`
|
||||
на обеих системах.
|
||||
3. **Проверка — по зелёному CI**, а не локально: локальный прогон на Linux эти
|
||||
две джобы не воспроизводит. Довести оба Windows-джоба до зелёного.
|
||||
|
||||
## P0-2. Остальные P0 аудита — подтвердить исполнением ПЕРЕД правкой
|
||||
|
||||
Ревьюер их не проверял. По каждому: сначала воспроизвести, потом чинить. Не
|
||||
чинить со слов аудита.
|
||||
|
||||
1. **Release Gate заявляет проверку хеша, которой не было** — частичный HTTP
|
||||
Range, но `PACKAGE_HASH_VERIFIED=True` без полного SHA-256. Прочитать
|
||||
`scripts/release_gate.py`, подтвердить, затем считать полный хеш или брать
|
||||
достоверный digest из release API.
|
||||
2. **Publication gate fail-open** — 404/сеть/отсутствие пакета возвращаются как
|
||||
PASS. Разделить Offline Gate (тесты, updater, статика, сборка) и Publication
|
||||
Gate (релиз есть, ассеты есть, digest сверен, скачивание прошло).
|
||||
3. **localhost `/api/action` без CSRF/Origin** — на loopback токен не требуется,
|
||||
а действие меняет состояние. Проверить, затем: bootstrap-токен, проверка
|
||||
`Origin`/`Sec-Fetch-Site`, авторизация небезопасных методов.
|
||||
|
||||
## P1. После зелёного main
|
||||
|
||||
1. **Zip-slip в updater**: распаковка обязана проверять каждый путь
|
||||
(`resolved.is_relative_to(staging)`), запрет абсолютных путей, `..`, symlink,
|
||||
device.
|
||||
2. **pricing fallback**: `safe_load` вместо `safe_dump` (см. выше).
|
||||
3. **CI-матрица Windows + Linux**: сейчас Linux-джоба нет, а проект на Linux и
|
||||
активно получает Linux-фиксы.
|
||||
4. Прочее из аудита (failover error policy, `uv sync --frozen`, лишний `web`
|
||||
extra, secret-scan шире) — отдельными заданиями, не в этом.
|
||||
|
||||
---
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Правки ревьюера из `main` не откатывать.
|
||||
- Фронтенд без npm/сборки/фреймворков — `docs/web-api/CONTRACT.md` §1.
|
||||
- Проверку SHA-256 и список разрешённых адресов обновления не ослаблять.
|
||||
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
|
||||
- Версию `0.1.3` не понижать.
|
||||
- Правило честности: неизмеренное — `Н/Д` с причиной.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Ветка в `origin`, `git status` чист.
|
||||
2. **Оба Windows-джоба CI зелёные** — ссылка на зелёный прогон в отчёте.
|
||||
3. `test_a37_isolation_guards` ловит `rm -rf $HOME/...` на Windows и Linux;
|
||||
guard fail-closed.
|
||||
4. verification-скрипт не падает на cp1252.
|
||||
5. Остальные P0 либо исправлены с доказательством, либо явно помечены как
|
||||
отложенные с причиной.
|
||||
6. `ruff check .` чисто; локальный прогон Linux зелёный; число тестов не меньше
|
||||
текущего.
|
||||
7. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`,
|
||||
`X passed / Y skipped / Z failed`, ссылка на зелёный CI.
|
||||
|
||||
## Главное
|
||||
|
||||
Первично — зелёный main, и обе причины уже найдены: security-guard на Windows и
|
||||
UTF-8 в verification. Остальные P0 аудита — только после подтверждения
|
||||
исполнением. Это фундамент под слияние: пока Hermes красный и его sandbox-guard
|
||||
дырявый на одной из систем, переносить его поведение в KAgent нельзя.
|
||||
|
||||
## Порядок сдачи
|
||||
Передать точный `FINAL_COMMIT_SHA` и ссылку на зелёный прогон CI.
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
# Задание A61: установщик и релизный конвейер — проверка на настоящей машине
|
||||
|
||||
## Для кого
|
||||
|
||||
**agy** (машина владельца, Windows, реальные учётные данные и `agy`). Не для
|
||||
серверной сессии: у неё нет `csc.exe`, нет Windows-реестра, нет прав публиковать
|
||||
релиз от имени владельца. Ревьюер (сессия на ПК) принимает.
|
||||
|
||||
## Дата поступления
|
||||
2026-09-03
|
||||
|
||||
## База
|
||||
|
||||
`origin/main` (`89435ea`).
|
||||
|
||||
```
|
||||
git fetch origin --prune
|
||||
git checkout -b installer/a61-live-verification origin/main
|
||||
```
|
||||
|
||||
В `main` напрямую не пушить.
|
||||
|
||||
---
|
||||
|
||||
## Задача
|
||||
|
||||
HUB-1 довёл CI до зелёного на Windows и Linux и закрыл дыру в релизных
|
||||
воротах: `release_gate.py` перестал заявлять проверку хеша, которой не было, и
|
||||
перестал быть fail-open при обрыве сети или 404. Заодно нашлось — и осталось
|
||||
непроверенным вживую, потому что для этого нужна настоящая Windows-машина, а
|
||||
не CI-раннер:
|
||||
|
||||
Установщик — единственный способ, которым продукт попадает к владельцу, и он
|
||||
**не проверяется нигде за пределами CI-раннера**, который сам его никогда не
|
||||
собирает. Ни один прогон `pytest -m installer` не выполнялся на настоящей
|
||||
установке. Ни один релиз ещё не прошёл через конвейер целиком — все прошлые
|
||||
теги падали на `Release Gate Check` (см. `agents/done/2026-09-02-HUB1-audit-p0-green-main.md`,
|
||||
раздел «Найдено сверх задания»), а действующие релизы на GitHub собраны и
|
||||
выложены вручную, мимо `release.yml`.
|
||||
|
||||
Это задание не про код хаба — про то, что установщик и конвейер публикации
|
||||
делают на реальной машине то же, что декларируют.
|
||||
|
||||
---
|
||||
|
||||
## Что уже проверено — заново не выяснять
|
||||
|
||||
### CI зелёный, но установщика не касается
|
||||
|
||||
`pyproject.toml`:
|
||||
```
|
||||
addopts = "-m 'not live and not network and not installer'"
|
||||
```
|
||||
Три теста в `tests/test_installer.py`, помеченные `@pytest.mark.installer`
|
||||
(`test_setup_exe_exists`, `test_silent_installer_execution_with_hermes`,
|
||||
`test_silent_installer_fails_without_hermes`), **исключены из каждого прогона**
|
||||
по умолчанию, и ни в `.github/workflows/ci.yml`, ни в `release.yml` нет шага,
|
||||
который передавал бы `-m installer` явно. К тому же все три сами пропускают
|
||||
себя (`pytest.skip`), если `dist/HermesHubSetup.exe` не собран — а его никто
|
||||
не собирает ни в CI, ни в конвейере релиза.
|
||||
|
||||
`tests/test_installer_windows_and_linux.py::test_windows_csharp_launchers_and_setup_compile`
|
||||
пропускается в CI с `csc.exe compiler not found in standard .NET Framework
|
||||
location` — компилятор ищется по путям `C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe`
|
||||
и `...\Framework\v4.0.30319\csc.exe`; на `windows-latest` раннере GitHub его
|
||||
нет. На обычной Windows 10/11 он есть — так и написано в
|
||||
`installer/README.md`: «compiles `HermesHubSetup.cs` using standard .NET
|
||||
Framework `csc.exe` present on all Windows 10/11 machines without extra
|
||||
toolchains».
|
||||
|
||||
### Тесты уже изолированы от твоего реестра
|
||||
|
||||
`test_silent_installer_execution_with_hermes` и
|
||||
`test_silent_installer_fails_without_hermes` подставляют `HERMES_HOME`,
|
||||
`LOCALAPPDATA`, `APPDATA`, `USERPROFILE` во временный каталог и ставят
|
||||
`HERMES_HUB_NO_REGISTRY=1` — это отключает запись в `HKCU\...\Uninstall`
|
||||
(закрыто ещё в A4, см. `agents/done/2026-08-21-A4-antigravity-credential-isolation.md`).
|
||||
Прогон этих тестов не трогает твой реальный реестр и твою реальную установку.
|
||||
`installer/README.md` отдельно требует того же: «Unit Tests: Must NEVER modify
|
||||
user Windows Registry or Start Menu shortcuts» — этому требованию тесты уже
|
||||
следуют, проверить нужно исполнением, а не читать код на слово.
|
||||
|
||||
### Релиз ещё никогда не публиковался этим конвейером
|
||||
|
||||
`gh run list --workflow=release.yml` на момент HUB-1 показывал failure на всех
|
||||
пяти последних тегах, включая `v0.1.3-b1` — падение на `Run Release Gate
|
||||
Check`, той же причине, что красила CI. HUB-1 эту причину устранил, но
|
||||
**ни разу после починки конвейер не запускался** — значит и новые шаги
|
||||
(`Built assets must be installable by the updater`,
|
||||
`Publication Gate (published release must be verifiable)`, оба добавлены в
|
||||
HUB-1) ни разу не выполнялись на настоящем прогоне GitHub Actions, только
|
||||
локально функциями напрямую.
|
||||
|
||||
---
|
||||
|
||||
## P0-1. Собрать установщик и прогнать installer-тесты на настоящей машине
|
||||
|
||||
1. Собрать: `installer/build_installer.ps1` (компилирует `HermesHub.cs`,
|
||||
`HermesHubWeb.cs`, `HermesHubSetup.cs` через `csc.exe`, кладёт
|
||||
`dist/HermesHubSetup.exe`). Приложить вывод сборки.
|
||||
2. Прогнать три `installer`-теста явно, отдельно от общего набора:
|
||||
```
|
||||
pytest -m installer tests/test_installer.py -v
|
||||
```
|
||||
Все три должны выполниться (не `SKIPPED`) и пройти. Приложить полный вывод.
|
||||
3. Прогнать `test_windows_csharp_launchers_and_setup_compile` отдельно —
|
||||
на твоей машине `csc.exe` должен найтись. Приложить вывод; если и здесь
|
||||
`SKIPPED` — назвать точный путь, по которому компилятор искался и не
|
||||
нашёлся, и где он есть на самом деле.
|
||||
4. **Подтвердить исполнением, что реестр не тронут**: снять состояние
|
||||
`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\HermesHub` до и
|
||||
после прогона (`reg query`), приложить оба вывода. Совпадают — тесты не
|
||||
соврали про изоляцию.
|
||||
|
||||
## P0-2. Полный цикл `/silent` на реальной установке
|
||||
|
||||
1. Установить через `dist/HermesHubSetup.exe /silent` в **реальный**
|
||||
(не временный) профиль — как ставит владелец.
|
||||
2. Проверить коды возврата по правилу из `agents/AGENTS.md` §4: `0`, `10`,
|
||||
`11`, `12` — на тех сценариях, для которых они определены (обычная
|
||||
установка, установка без Hermes Agent, повторная установка, откат).
|
||||
Каждый код — с описанием сценария, который его вызвал.
|
||||
3. После установки — обычный рабочий цикл: хаб запускается, видит существующие
|
||||
профили `agy`, ничего не потеряно. Если что-то потерялось — это находка, а
|
||||
не повод откатывать проверку молча.
|
||||
4. **Не удалять существующие профили и учётные данные для эксперимента.**
|
||||
Если для чистоты нужна отдельная установка — использовать переменные
|
||||
изоляции (`HERMES_HOME` и т.д.), как это уже делают тесты, а не боевой
|
||||
каталог.
|
||||
|
||||
## P0-3. Один настоящий прогон релизного конвейера — без публикации владельцу
|
||||
|
||||
Цель — увидеть, что новые шаги `release.yml` (Release Gate → сборка →
|
||||
проверка пригодности ассетов → публикация → Publication Gate) действительно
|
||||
отрабатывают на GitHub Actions, а не только в теории.
|
||||
|
||||
1. **Не создавать публичный релиз без отдельного разрешения владельца.**
|
||||
Вместо реального тега: либо (а) временный форк/ветка с ручным запуском
|
||||
`workflow_dispatch`, если конвейер его поддерживает — иначе не добавлять
|
||||
`workflow_dispatch` ради этого задания, это отдельное решение; либо (б)
|
||||
прогнать шаги локально в том порядке, в котором их вызывает `release.yml`:
|
||||
```
|
||||
python scripts/release_gate.py
|
||||
# сборка через build_installer.ps1 в dist/
|
||||
python scripts/release_gate.py --assets dist
|
||||
```
|
||||
и явно объяснить, что осталось непроверенным без настоящей публикации
|
||||
(шаг `Publish GitHub Release` и `--publication-only` после него).
|
||||
2. Если владелец в диалоге явно разрешит настоящий тестовый тег — тогда можно
|
||||
довести до конца, включая `release_gate.py --publication-only` на
|
||||
опубликованном релизе. **Без этого разрешения — не пушить тег.**
|
||||
3. Итог — что именно проверено, а что нет и почему (например: «сборка и
|
||||
проверка пригодности ассетов проверены локально в точности как в
|
||||
`release.yml`; публикация и Publication Gate не проверены — нужен реальный
|
||||
тег, разрешения не спрашивал/владелец отказал»).
|
||||
|
||||
## P0-4. Проверка исполнением, а не по чтению кода
|
||||
|
||||
Как и в HUB-1: там, где что-то не запускалось — не писать «должно работать»,
|
||||
запустить и приложить вывод. Не удалось — сказать `Н/Д` с точной причиной
|
||||
(например: «на этой машине нет .NET Framework 4.0, только .NET 8» — если это
|
||||
окажется так).
|
||||
|
||||
---
|
||||
|
||||
## Ограничения
|
||||
|
||||
- **Не публиковать релиз на GitHub без явного разрешения владельца в этом
|
||||
диалоге.** Прогон `release.yml` через настоящий тег создаёт публичный релиз.
|
||||
- Реальные учётные данные и `~/.hermes/agy_profiles/` не удалять и не менять
|
||||
ради эксперимента; для изоляции — переменные окружения, как в существующих
|
||||
тестах.
|
||||
- Правки ревьюера из `main` не откатывать; правки HUB-1 (P0-1, P0-2 из этого
|
||||
задания опираются на них) не переписывать без причины.
|
||||
- Версию `0.1.3` не понижать и не менять без необходимости.
|
||||
- Правило честности без исключений: неизмеренное — `Н/Д` с причиной.
|
||||
- Если для `workflow_dispatch` нужно менять `.github/workflows/release.yml` —
|
||||
делать это отдельным, явно описанным шагом, не молча.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. Ветка в `origin`, `git status` чист.
|
||||
2. `dist/HermesHubSetup.exe` собран на настоящей Windows-машине; вывод сборки
|
||||
приложен.
|
||||
3. Все installer-тесты (`pytest -m installer` + компиляция C#) выполнены не
|
||||
как `SKIPPED`; вывод каждого приложен.
|
||||
4. `HKCU\...\Uninstall\HermesHub` до и после прогона тестов идентичен —
|
||||
оба снятых состояния приложены.
|
||||
5. `/silent` установка проверена на реальном профиле; коды возврата названы
|
||||
со сценарием каждого.
|
||||
6. Локальный прогон шагов `release.yml` (Release Gate → сборка → проверка
|
||||
ассетов) воспроизведён и приложен; либо — с явного разрешения владельца —
|
||||
доведён до настоящего тега и `--publication-only`.
|
||||
7. Каждый непроверенный пункт назван явно, с причиной — не пропущен молча.
|
||||
8. Отчёт: что собрано, что запущено, точные команды и их вывод, что осталось
|
||||
`Н/Д` и почему.
|
||||
|
||||
## Главное
|
||||
|
||||
HUB-1 сделал ворота честными на уровне кода: они больше не заявляют проверку,
|
||||
которой не было. Это задание проверяет ту же честность на уровне машины —
|
||||
что установщик, который получит владелец, действительно собирается, ставится
|
||||
и обновляется так, как об этом говорит код. Пока это не проверено на
|
||||
настоящей Windows, «зелёный CI» доказывает только код, а не установщик.
|
||||
|
||||
## Порядок сдачи
|
||||
Передать точный `FINAL_COMMIT_SHA` и полный вывод всех проверок из P0-1—P0-3.
|
||||
22
docs/research/README.md
Normal file
22
docs/research/README.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Разведка и идеи
|
||||
|
||||
Здесь — то, что рассмотрено для Hermes Hub, но ещё не решено делать: чужие
|
||||
проекты, откуда стоит перенять устройство, наблюдения из новостей, отложенные
|
||||
замыслы. Отдельно от `ARCHITECTURE.md`: тот описывает **что построено**, а этот
|
||||
каталог — **что взвешено и почему**.
|
||||
|
||||
Правило одно: сюда попадает только то, что проверено рассуждением и привязано к
|
||||
нашей машине и нашим ограничениям, а не пересказ рекламных обещаний. У каждой
|
||||
записи — дата, вердикт и причина вердикта. Устаревшее не удалять молча:
|
||||
помечать, что и когда изменилось.
|
||||
|
||||
## Записи
|
||||
|
||||
- [agent-orchestrator.md](agent-orchestrator.md) — разбор Agent Orchestrator,
|
||||
ближайшего архитектурного родственника Hub; что перенять, чего не брать.
|
||||
- [scouting-log.md](scouting-log.md) — журнал разведки: что рассмотрено по датам,
|
||||
с вердиктом по каждому пункту и причиной.
|
||||
- [kagent-merge-decision.md](kagent-merge-decision.md) — решение о слиянии с
|
||||
KAgent: проверенные ревьюером находки, жёсткий гейт на перенос ключей, порядок.
|
||||
- [kagent-merge-plan.md](kagent-merge-plan.md) — сам план миграции Hermes → KAgent
|
||||
по фазам, положен в репозиторий как артефакт.
|
||||
71
docs/research/agent-orchestrator.md
Normal file
71
docs/research/agent-orchestrator.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Разбор: Agent Orchestrator
|
||||
|
||||
**Источник:** сводка AI Daily News за 2026-08-31.
|
||||
**Лицензия по сводке:** Apache 2.0, локальный запуск.
|
||||
**Вердикт:** разобрать устройство и перенять две идеи; целиком не брать.
|
||||
**Дата разбора:** 2026-09-02.
|
||||
|
||||
> Первоисточник перед внедрением перепроверить: сводка — это пересказ, а не сам
|
||||
> проект. Ссылку и точное имя репозитория подтвердить.
|
||||
|
||||
## Почему он для нас важен
|
||||
|
||||
Это ближайший архитектурный родственник того, что строит Hermes Hub. Он тоже
|
||||
раздаёт работу флоту coding-агентов (Claude Code, Codex, Aider, OpenCode, Cline,
|
||||
Continue, Goose и другие) и держит постоянную роль оркестратора — планирование,
|
||||
делегирование, координация, — а работники заняты реализацией, тестами, PR и
|
||||
исправлениями. Ровно наша схема ролей из `ROUTER.md`.
|
||||
|
||||
Ценность не в том, чтобы взять его вместо Hub, а в том, что он уже прошёл путь,
|
||||
на котором мы сейчас: у нас роли есть, но замечания исполнителю пока возвращает
|
||||
живой ревьюер вручную.
|
||||
|
||||
## Что перенять
|
||||
|
||||
### 1. Возврат замечаний исполнителю — то, чего у нас нет
|
||||
|
||||
У них отказы CI и замечания ревью **маршрутизируются обратно нужному агенту**, и
|
||||
петля замыкается автоматически. У нас этого звена нет: агент присылает отчёт,
|
||||
ревьюер проверяет исполнением, находит расхождение — и передаёт правку заново
|
||||
руками. За 1–2 сентября так было трижды (A56, A57, ложный «16/16»).
|
||||
|
||||
Стоит спроектировать: результат проверки (прошло / не прошло + причина + номер
|
||||
задания) возвращается тому исполнителю и в ту ветку, откуда пришла работа. Это
|
||||
естественное продолжение линии заданий, которую ведёт `agents/inbox`.
|
||||
|
||||
### 2. Ветка и worktree на каждого работника
|
||||
|
||||
Каждый работник получает отдельный git worktree, отдельную ветку и отдельное
|
||||
состояние сессии. Мы это уже делаем вручную — каждое задание Antigravity живёт в
|
||||
своей ветке `antigravity/aNN-*`, ревьюер сливает в `main`. У них это часть
|
||||
системы, а не ручной обычай. Формализовать наш обычай стоит.
|
||||
|
||||
### 3. Отслеживание PR / CI / merge conflicts / состояния работника
|
||||
|
||||
Единая доска состояния флота. У нас это разбросано: `agents/inbox`,
|
||||
`agents/done`, `agents/reports` и git-ветки. Свести в один обзор — понятная
|
||||
польза, когда исполнителей несколько и они на разных машинах.
|
||||
|
||||
## Чего НЕ брать
|
||||
|
||||
- **Целиком как замену Hub.** Hub — маршрутизатор провайдеров внутри Hermes
|
||||
Agent, а не автономная IDE для флота. Задачи пересекаются, но не совпадают.
|
||||
- **Прямой доступ агентов к рабочему окружению без ограничений.** Здесь держать
|
||||
в уме вывод из отчёта OpenAI Astra (сводка за 2026-09-02): автономным агентам
|
||||
ограничения должны обеспечиваться инфраструктурой, а не системным запросом. У
|
||||
нас противовес уже есть — изоляция из A37 и правило про `agy_profiles`;
|
||||
ослаблять его ради удобства оркестрации нельзя.
|
||||
|
||||
## Что проверить у первоисточника перед любым внедрением
|
||||
|
||||
- точное имя и адрес репозитория, реальную лицензию (в коде, не в сводке);
|
||||
- как именно возвращаются замечания — формат, транспорт, привязка к ветке;
|
||||
- требования к окружению и зрелость проекта (возраст, тесты, сообщество);
|
||||
- модель прав: что работник может делать с репозиторием и системой.
|
||||
|
||||
## Связанное
|
||||
|
||||
- Роли и цепочки: [../ROUTER.md](../ROUTER.md).
|
||||
- Изоляция агентов и запрет на чужие ключи: A37, `agents/inbox/2026-08-30-A37-*`.
|
||||
- Наблюдаемость сессий агентов — см. AgentsView в
|
||||
[scouting-log.md](scouting-log.md).
|
||||
113
docs/research/kagent-merge-decision.md
Normal file
113
docs/research/kagent-merge-decision.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# Решение: слияние Hermes Hub и KAgent
|
||||
|
||||
**Дата:** 2026-09-02.
|
||||
**Статус:** направление принято владельцем; исполнение — по условиям ниже.
|
||||
**Ревьюер проверил исполнением** обе стороны, насколько имел доступ.
|
||||
|
||||
---
|
||||
|
||||
## Решение
|
||||
|
||||
Вести один продукт — **KAgent** как единую AI-платформу. Функциональность
|
||||
Hermes Hub переносится в KAgent нативно, Hermes Hub после достижения parity
|
||||
архивируется. Полный план — [kagent-merge-plan.md](kagent-merge-plan.md).
|
||||
|
||||
Организация работы на переходный период (решение владельца от 2026-09-02):
|
||||
|
||||
- **KAgent** дорабатывается на одном сервере;
|
||||
- **Hermes Hub** доводится на втором сервере;
|
||||
- после доработки — слияние;
|
||||
- **KAgent готовится к слиянию сразу**, с первого дня: контракты и модель
|
||||
безопасности проектируются под будущий перенос, а не подгоняются потом.
|
||||
|
||||
---
|
||||
|
||||
## Что ревьюер проверил сам, а не взял из аудита
|
||||
|
||||
Аудиты — тоже отчёты, поэтому проверены исполнением. Спот-проверка совпала с
|
||||
аудитами на конкретных утверждениях — значит доверять им можно, но с поправками
|
||||
ниже.
|
||||
|
||||
### Hermes Hub
|
||||
|
||||
- **CI на `main` красный** — подтверждено, несколько падений 2026-09-02.
|
||||
- **Баг pricing fallback реален**: `telemetry_service.py:164` делает
|
||||
`yaml.safe_dump(p.read_text(...))` вместо `safe_load`, затем проверяет
|
||||
`isinstance(data, dict)` — всегда ложно, и `except: pass` это глушит. Таблица
|
||||
цен из `pricing.yaml` не загружается никогда. Аудит: P2. Подтверждено.
|
||||
|
||||
### KAgent (репозиторий `ochenstarik-ui/kagent`, head `131c9b08`)
|
||||
|
||||
- **Лицензии нет**, репозиторий публичный — подтверждено.
|
||||
- **Публичный расход средств подтверждён чтением `services/reasoning-engine/src/server.py`:**
|
||||
функция `require_operator_secret` существует и применяется к управлению
|
||||
аккаунтами (`/v1/accounts`, `pin`, `disable`, `reset-throttle`), но **НЕ**
|
||||
применяется к `/v1/execute`, `/v1/decide`, `/v1/telemetry`, `/v1/models`.
|
||||
`/v1/execute` вызывает `engine.execute(...)` — реальный расход. То есть с
|
||||
подключёнными ключами любой, кто найдёт порт, тратит квоты без авторизации.
|
||||
Это не гипотеза, а код на `main`.
|
||||
- **Поправка к аудиту:** аудит датирован 2026-09-02 и говорит об «активности
|
||||
после релиза», но последний push в KAgent — **18–19 августа**, две недели
|
||||
тишины. CI зелёный, но старый. KAgent сейчас не разрабатывается активно. На
|
||||
выводы о коде это не влияет (head-коммит совпал), на планирование сроков —
|
||||
влияет.
|
||||
|
||||
---
|
||||
|
||||
## Жёсткий гейт (не обсуждается)
|
||||
|
||||
**Ни один ключ провайдера не переезжает в KAgent, пока `/v1/execute`,
|
||||
`/v1/decide` и `/v1/telemetry` не закрыты авторизацией и это не проверено живым
|
||||
запросом.** У владельца ~2 десятка оплаченных аккаунтов. Пока маршруты открыты,
|
||||
KAgent небезопасен даже без слияния — это надо чинить в нём независимо.
|
||||
|
||||
Это соответствует Phase 0 плана слияния и P0 аудита KAgent.
|
||||
|
||||
---
|
||||
|
||||
## Порядок, который советует ревьюер
|
||||
|
||||
Направление верное — два оркестратора не нужны, Крона не должна знать о Hermes.
|
||||
Но последовательность важнее скорости:
|
||||
|
||||
1. **Hermes довести до зелёного и стабильного** прежде, чем замораживать. Он —
|
||||
эталон переноса (reference implementation). Сломанный эталон нельзя
|
||||
портировать: parity-тесты будут сверяться с неверным поведением. Сегодня
|
||||
Hermes ещё нестабилен — аккаунты едва работают, `agy`-патч слетает после
|
||||
перезагрузки, `main` красный.
|
||||
2. **KAgent Phase 0 (безопасность) — закрыть и проверить исполнением**, начиная
|
||||
ровно с четырёх незакрытых маршрутов. До этого — никаких ключей.
|
||||
3. **Контракт выполнения (Phase 1)** можно проектировать уже сейчас, риска нет:
|
||||
`AIExecutionRequest`, `AIExecutionResult`, `ProviderAdapter`, таксономия
|
||||
ошибок, `RoutingDecision`.
|
||||
|
||||
## Почему «rewrite не нужен» — неточность
|
||||
|
||||
Скелет KAgent есть, но роутер Hermes не портируется построчно: он переезжает в
|
||||
другую архитектуру (Rust gateway, TS control plane, Python-сервисы,
|
||||
распределённое состояние Redis/Postgres/NATS вместо процесса). Это честный
|
||||
rewrite роутера. Сроки планировать от этого.
|
||||
|
||||
## Что перенести из Hermes (проверенные тонкости, легко потерять при переносе)
|
||||
|
||||
Эти вещи вскрылись только живым прогоном и обязаны попасть в parity-набор:
|
||||
|
||||
- вход `agy` читается из `.gemini/antigravity-cli/antigravity-oauth-token`, не из
|
||||
формата Gemini CLI;
|
||||
- терминалу входа нельзя подменять `HOME` (X11 берёт ключ из `~/.Xauthority`);
|
||||
- `/props` и `/tokenize` у llama.cpp — в корне, не под `/v1`;
|
||||
- слот выбирается до входа и не должен плодиться; запрос пути профиля не должен
|
||||
создавать каталог;
|
||||
- проверка после подключения не блокирует ответ;
|
||||
- честное `Н/Д` с причиной вместо правдоподобных чисел.
|
||||
|
||||
Подробности — в [../../agents/](../../agents/) и передаточном брифе.
|
||||
|
||||
---
|
||||
|
||||
## Координация
|
||||
|
||||
Две сессии Claude пишут в один `main` Hermes Hub (сессия на ПК — ревьюер; сессия
|
||||
на сервере под `ochenstarik` — исполнитель). Плюс крупный разворот стратегии.
|
||||
Обе сессии должны видеть это решение. Перед пушем — `git fetch` и сверка
|
||||
`git log --oneline origin/main`.
|
||||
107
docs/research/kagent-merge-plan.md
Normal file
107
docs/research/kagent-merge-plan.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# План слияния Hermes Hub → KAgent
|
||||
|
||||
Источник — план владельца от 2026-09-02, положен в репозиторий, чтобы не жил
|
||||
только файлом на рабочем столе. Оценка и условия исполнения — в
|
||||
[kagent-merge-decision.md](kagent-merge-decision.md).
|
||||
|
||||
## Цель
|
||||
|
||||
KAgent становится единой AI Agent Operating Platform. Функциональность Hermes Hub
|
||||
переносится нативно, Hermes Hub и Hermes Agent перестают быть зависимостями,
|
||||
Hermes Hub архивируется. Hermes Hub на переходный период — донор функциональности
|
||||
и эталон поведения, не встраиваемая библиотека.
|
||||
|
||||
## Разделение обязанностей
|
||||
|
||||
- **Orchestrator** выбирает агента, workflow, инструменты, контекст, проверку,
|
||||
момент завершения.
|
||||
- **AI Router** выбирает провайдера, модель, аккаунт, локально/облако, failover,
|
||||
проверяет квоту, доступность, бюджет, вычислительный узел.
|
||||
|
||||
> Orchestrator выбирает агента и задачу. Router выбирает модель, провайдера и
|
||||
> аккаунт.
|
||||
|
||||
## Что переносится из Hermes
|
||||
|
||||
Multi-provider router; адаптеры провайдеров (Antigravity, Claude, Codex,
|
||||
DeepSeek, Grok, Local, NVIDIA, Ollama, OpenCode, OpenRouter); менеджер
|
||||
аккаунтов/профилей (несколько аккаунтов на провайдера, приоритет, quota,
|
||||
cooldown, health, concurrency); health-состояния; quota manager; session
|
||||
affinity; lease/concurrency; model registry; capability-routing; локальные
|
||||
модели и вычислительные узлы; agent registry (15 ролей как декларативные
|
||||
Agent Definition); Dual Coder как workflow; Guardian как policy-слой; Cost
|
||||
Controller как системная подсистема; failover-policy с таксономией ошибок;
|
||||
telemetry в существующий Observability; audit routing-решений.
|
||||
|
||||
## Что НЕ переносить
|
||||
|
||||
Hermes-specific bootstrap; дублирующий Web API и отдельный UI; process-local
|
||||
архитектуру; JSONL как основное хранилище telemetry; формат настроек Hermes;
|
||||
update flow Hermes; роль orchestrator как отдельный runtime; код, привязанный к
|
||||
структуре Hermes Agent; compatibility-слои, не нужные после миграции.
|
||||
|
||||
Секреты: не переносить хранилище Hermes один-в-один. Порядок — внешний Secret
|
||||
Manager → OS/keyring → шифрованное хранение в БД → материализация только на время
|
||||
запроса. Запрещено: ключи в обычных JSON, отдача секретов через API, секреты в
|
||||
telemetry/audit/трейсах.
|
||||
|
||||
## Фазы
|
||||
|
||||
- **Phase 0 — Security baseline (блокер).** Auth/RBAC; защита расхода провайдера;
|
||||
service-auth; безопасное хранение секретов; уникальная request identity;
|
||||
реальный E2E. Выход: нет неавторизованного execution и мутаций проекта/задачи;
|
||||
расход защищён; CI зелёный; E2E по настоящему пути зелёный.
|
||||
- **Phase 1 — контракты Router.** AIExecutionRequest, AIExecutionResult,
|
||||
ProviderAdapter, Model/Account descriptor, таксономия ошибок, RoutingDecision.
|
||||
Провайдеры пока не переносить. Выход: contract-тесты, fake-адаптер, роутер на
|
||||
тестовых провайдерах.
|
||||
- **Phase 2 — Provider SDK.** timeout, cancellation, streaming, маппинг ошибок,
|
||||
usage, cost, health, discovery. Выход: новый провайдер добавляется без правки
|
||||
ядра.
|
||||
- **Phase 3 — перенос адаптеров.** Порядок: openai-compatible → Claude →
|
||||
OpenRouter → Google/Antigravity → Grok → DeepSeek → NVIDIA → Ollama → Codex →
|
||||
OpenCode → local. Для каждого: parity, таксономия ошибок, health/auth/
|
||||
streaming/timeout/quota/regression тесты.
|
||||
- **Phase 4 — Account Manager.** Безопасные credentials, приоритет, quota,
|
||||
cooldown, health, concurrency, переходы состояний.
|
||||
- **Phase 5 — Router Engine.** role/capability routing, scoring, preferred chain,
|
||||
health/quota awareness, same-account и cross-account/provider fallback, session
|
||||
affinity, auto-return primary, failover trace.
|
||||
- **Phase 6 — распределённое состояние.** Redis (health, leases, affinity,
|
||||
cooldown), PostgreSQL (providers, accounts, models, policies, budgets, usage,
|
||||
nodes, agents).
|
||||
- **Phase 7 — локальные модели / compute nodes.** node agent: регистрация,
|
||||
heartbeat, инвентарь моделей и ресурсов, execution, queue, GPU.
|
||||
- **Phase 8 — Agent Registry.** декларативные определения: capabilities, tools,
|
||||
permissions, routing policy, budgets, model constraints.
|
||||
- **Phase 9 — Guardian** как policy enforcement: валидация команд, границы ФС,
|
||||
сигналы prompt injection, детект секретов, проверка прав инструментов, сетевая
|
||||
политика, классификация разрушительных действий.
|
||||
- **Phase 10 — Cost Controller.** оценочная и фактическая стоимость, жёсткие
|
||||
бюджеты, наследование, cloud/local оптимизация, alerts, kill switch.
|
||||
- **Phase 11 — Workflows.** Dual Coder как workflow; шаблоны Coder+Reviewer,
|
||||
Coder+Tester, Security Review, Multi-model Consensus, Local Draft + Cloud
|
||||
Review.
|
||||
- **Phase 12 — UI.** разделы Providers, Accounts, Models, Routing, Nodes, Quotas,
|
||||
Budgets, Usage, Health, Agents.
|
||||
- **Phase 13 — parity-тесты.** Чек-лист до отключения Hermes: все провайдеры,
|
||||
несколько аккаунтов, quota exhaustion, rate limit, auth failure, failover,
|
||||
локальные модели, session affinity, выбор модели, health, telemetry, Dual
|
||||
Coder, роли.
|
||||
- **Phase 14 — decommission Hermes.** запрет новых фич → deprecated → KAgent
|
||||
единственный production path → удаление зависимостей → финальный релиз Hermes →
|
||||
archived.
|
||||
|
||||
## Критерий отказа от Hermes
|
||||
|
||||
Архивировать только когда KAgent умеет: все нужные облачные провайдеры;
|
||||
несколько аккаунтов; локальный AI; авто-выбор модели; failover; учёт quota;
|
||||
health; session affinity; telemetry; расчёт cost; роли агентов; эквивалент Dual
|
||||
Coder; проверки Guardian; бюджеты; работу на Windows/Linux; полный parity-набор.
|
||||
|
||||
## Обязательные P0 KAgent до миграции (из его аудита, часть проверена ревьюером)
|
||||
|
||||
Control Plane auth/RBAC (не доверять `x-actor-id`); авторизация
|
||||
`/v1/execute` и `/v1/decide` (**подтверждено: сейчас открыты**); уникальная
|
||||
request identity; фикс double-consume TOTP; настоящий E2E через Gateway, не mock;
|
||||
добавить LICENSE (**подтверждено: отсутствует**).
|
||||
94
docs/research/scouting-log.md
Normal file
94
docs/research/scouting-log.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Журнал разведки
|
||||
|
||||
Что рассмотрено для Hermes Hub, с вердиктом и причиной. Причина важнее вердикта:
|
||||
она объясняет, почему решение такое, и не даёт вернуться к отвергнутому через
|
||||
месяц, забыв доводы.
|
||||
|
||||
Наша машина, чтобы вердикты были понятны: сервер — одна **Tesla V100 32 ГиБ**
|
||||
(Volta, sm_70), кодер Qwen3-Coder-30B-A3B занимает ~30 ГиБ из 32, свободно ~2.
|
||||
Провайдеры подключаются аккаунтами через хаб.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-02
|
||||
|
||||
### Взять
|
||||
|
||||
**Claude Fable 5.1** — новая frontier-модель Anthropic под длительные
|
||||
coding/agent-задачи. Цена $10 / $50 за миллион (вход/выход). Ложится на нашу
|
||||
маршрутизацию по ролям как ревьюер и второй кодер; на повседневное не ставить
|
||||
из-за цены. Проверяется через уже подключённый аккаунт Claude, отдельного
|
||||
провайдера не требует. **Вердикт: протестировать на одной сложной задаче,
|
||||
сравнить с текущими кодером и ревьюером по времени, стоимости и качеству.**
|
||||
|
||||
### Внимание, не улучшение
|
||||
|
||||
**Hermes Agent v0.21.0** — не обновлять сервер первым. В сводке два бага:
|
||||
`ollama_num_ctx` может ограничить контекст облачного провайдера локальным
|
||||
лимитом Ollama (в отчёте 65 536 при заявленном 1M); shutdown-watchdog зовёт
|
||||
`asyncio.start_unix_server`, которого нет в родном Python под Windows. Проверено:
|
||||
ни `AF_UNIX`, ни `ollama_num_ctx` в коде хаба нет — оба дефекта в самом Hermes
|
||||
Agent, но хаб работает его плагином. **Отдельно:** 65 536 — это ещё и наше
|
||||
запасное значение `n_ctx` в `local_supervisor.query_server_props`, когда `/props`
|
||||
недоступен. Числа совпадают, дефекты разные — не перепутать при разборе.
|
||||
**Вердикт: сервер обновлять последним, после Windows-машины (canary first).**
|
||||
|
||||
### Перенять устройство
|
||||
|
||||
**Agent Orchestrator** (Apache 2.0, локальный) — ближайший родственник Hub.
|
||||
Подробный разбор: [agent-orchestrator.md](agent-orchestrator.md). Коротко:
|
||||
перенять автоматический возврат замечаний исполнителю (у нас его нет), ветку и
|
||||
worktree на работника, единую доску состояния флота; целиком не брать.
|
||||
|
||||
**Наблюдаемость — паттерн NVIDIA BioNeMo / Claude Science:** оркестратор + узкие
|
||||
инструменты вместо «одна модель делает всё». Архитектурно полезно для Кроны, не
|
||||
для Hub напрямую. **Вердикт: держать в уме для Кроны.**
|
||||
|
||||
### Отложить
|
||||
|
||||
**AgentsView** (MIT, локальный, без аккаунта) — местная аналитика сессий
|
||||
coding-агентов: единый индекс, SQLite, токены и стоимость по агентам и датам,
|
||||
сбор с нескольких машин. Полезно: у нас теперь сессии на двух машинах и
|
||||
несколько исполнителей (Claude, Antigravity, Codex), и вопрос «куда ушли токены»
|
||||
встанет скоро. Риск низкий. **Вердикт: протестировать локально, когда дойдут
|
||||
руки; не срочно, пока хаб не стабилизирован.**
|
||||
|
||||
### Не для нас сейчас
|
||||
|
||||
**ARD (Agentic Resource Discovery)** — слой обнаружения инструментов, чтобы не
|
||||
грузить весь каталог в prompt. Идея верная, но преждевременная: сначала хаб
|
||||
должен надёжно видеть подключённые аккаунты. **Вердикт: следить за стандартом,
|
||||
не внедрять.**
|
||||
|
||||
**ComfyUI MCP, VoiceStudio, Gemini Agentic Video, DreamX-Creator** — медиа и
|
||||
видео. Не про Hermes Hub; относится к SMM/медийным проектам среди прочих
|
||||
каталогов `/srv/projects`. **Вердикт: не в Hub.**
|
||||
|
||||
**Rapid-MLX, MLX Workbench, CAVI MLX Agent** — только Apple Silicon. Mac-узла
|
||||
нет. **Вердикт: наблюдать до появления Mac.**
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-01
|
||||
|
||||
### Проверять, а не брать на веру
|
||||
|
||||
**llama.cpp свежие сборки** (flash-attention под CUDA, shared-memory K/V,
|
||||
MoE-fusion). Новые пути CUDA обычно рассчитаны на Ampere и новее; V100 — Volta
|
||||
(sm_70), выигрыш не гарантирован. **Вердикт: если пробовать — только с повторным
|
||||
замером тех же 107,4 ток/с на нашей сборке; по умолчанию не выигрыш.**
|
||||
|
||||
**Qwen3.8-27B MTP / спекулятивное декодирование** — приведённые замеры на RTX
|
||||
3090 и 5090. У нас черновой модели некуда встать: кодер занимает 30 ГиБ из 32. И
|
||||
в A52 уже измерено — две модели на одной V100 делят пропускную способность
|
||||
памяти 0,78–0,99×, ускорения нет. **Вердикт: не тратить время на нашей машине.**
|
||||
|
||||
### Паттерн, не продукт
|
||||
|
||||
**AWS Agent Toolkit** — паттерн `Skills + restricted MCP + policy + audit`.
|
||||
Совпадает с линией изоляции A37 и моделью безопасности (`SECURITY_MODEL.md`).
|
||||
**Вердикт: держать как ориентир для Tool/Skill-подсистемы, продукт не тащить.**
|
||||
|
||||
**TradingAgents v0.4.0** — не для Hub; идеи point-in-time (защита от заглядывания
|
||||
в будущее, historical snapshot, отметки времени в памяти решений) — для
|
||||
финансовых проектов. **Вердикт: не в Hub, передать в торговые проекты.**
|
||||
|
|
@ -86,7 +86,17 @@ namespace HermesHubSetup
|
|||
}
|
||||
}
|
||||
|
||||
string defaultTarget = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Programs\HermesHub");
|
||||
// GetFolderPath(LocalApplicationData) не всегда возвращает то, что
|
||||
// ждёт установщик — найдено живым прогоном (A61): в некоторых
|
||||
// окружениях (изолированный тестовый профиль, нестандартный
|
||||
// пользовательский куст реестра) значение расходится с
|
||||
// фактическим %LOCALAPPDATA%. Читаем переменную окружения первой.
|
||||
string localAppData = Environment.GetEnvironmentVariable("LOCALAPPDATA");
|
||||
if (string.IsNullOrEmpty(localAppData))
|
||||
{
|
||||
localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
}
|
||||
string defaultTarget = Path.Combine(localAppData, @"Programs\HermesHub");
|
||||
TargetInstallDir = defaultTarget;
|
||||
|
||||
// Check if already installed
|
||||
|
|
@ -595,6 +605,12 @@ namespace HermesHubSetup
|
|||
|
||||
private static void CreateStartMenuShortcut()
|
||||
{
|
||||
// Изолированные прогоны (HERMES_HUB_NO_REGISTRY=1) уже не пишут в
|
||||
// реестр (см. HERMES_HUB_NO_REGISTRY ниже), но ярлык в настоящем
|
||||
// меню Пуск владельца этим не перекрывался — найдено живым
|
||||
// прогоном тестов на A61: /silent-тест с этой переменной всё
|
||||
// равно оставлял значок в реальном Пуск.
|
||||
if (Environment.GetEnvironmentVariable("HERMES_HUB_NO_REGISTRY") == "1") return;
|
||||
try
|
||||
{
|
||||
string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs);
|
||||
|
|
@ -641,6 +657,7 @@ namespace HermesHubSetup
|
|||
|
||||
private static void RemoveStartMenuShortcut()
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("HERMES_HUB_NO_REGISTRY") == "1") return;
|
||||
try
|
||||
{
|
||||
string startMenu = Environment.GetFolderPath(Environment.SpecialFolder.Programs);
|
||||
|
|
|
|||
|
|
@ -49,51 +49,8 @@ echo ""
|
|||
# крутить старый код в памяти, и владелец видел прежний интерфейс при новом
|
||||
# номере сборки. Три сборки подряд ставились в файлы, но не в работу.
|
||||
echo "[0/6] Остановка работающего Hermes Hub..."
|
||||
|
||||
stop_running_hub() {
|
||||
local pattern="antigravity_provider.router.web|hermes_hub_web_entry"
|
||||
local pids
|
||||
# Только процессы ЭТОГО пользователя и только те, что относятся к хабу.
|
||||
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '
|
||||
' ' ')"
|
||||
|
||||
if [ -z "$pids" ]; then
|
||||
echo " Работающий хаб не найден — останавливать нечего."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo " Найдены процессы хаба: $pids"
|
||||
# shellcheck disable=SC2086
|
||||
kill $pids 2>/dev/null || true
|
||||
|
||||
local waited=0
|
||||
while [ "$waited" -lt 10 ]; do
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '
|
||||
' ' ')"
|
||||
[ -z "$pids" ] && break
|
||||
done
|
||||
|
||||
if [ -n "$pids" ]; then
|
||||
echo " Не завершились за 10 секунд, снимаю принудительно: $pids"
|
||||
# shellcheck disable=SC2086
|
||||
kill -9 $pids 2>/dev/null || true
|
||||
sleep 1
|
||||
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '
|
||||
' ' ')"
|
||||
fi
|
||||
|
||||
if [ -n "$pids" ]; then
|
||||
# Не прерываем установку: файлы обновятся, а владельцу скажем правду.
|
||||
echo " ⚠ Остались процессы: $pids. Снимите их вручную, иначе будет работать старый код."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo " Хаб остановлен."
|
||||
return 0
|
||||
}
|
||||
|
||||
# shellcheck source=./lib_stop_running_hub.sh
|
||||
. "$SCRIPT_DIR/lib_stop_running_hub.sh"
|
||||
stop_running_hub || true
|
||||
echo ""
|
||||
|
||||
|
|
@ -266,9 +223,35 @@ mkdir -p "$HERMES_HOME/bin"
|
|||
cp "$LAUNCHER_SRC" "$HERMES_HOME/bin/hermes-hub-web"
|
||||
chmod +x "$HERMES_HOME/bin/hermes-hub-web"
|
||||
|
||||
# Лаунчер остановки — эквивалент «Exit» из системного трея Windows.
|
||||
#
|
||||
# На Windows сервер стартует из HermesHubWeb.exe, который держит значок в
|
||||
# трее: закрыть его оттуда может сам владелец. На Linux сервер после закрытия
|
||||
# окна остаётся в фоне без единого способа его остановить — ни кнопки в
|
||||
# интерфейсе (её нет ни на одной платформе), ни трея, ни пункта меню. Кладём
|
||||
# lib_stop_running_hub.sh рядом со скриптом остановки: он ищет её сначала
|
||||
# рядом с собой.
|
||||
STOP_LAUNCHER_SRC="$REPO_ROOT/launcher/hermes-hub-stop.sh"
|
||||
if [ ! -f "$STOP_LAUNCHER_SRC" ]; then
|
||||
STOP_LAUNCHER_SRC="$SCRIPT_DIR/../launcher/hermes-hub-stop.sh"
|
||||
fi
|
||||
STOP_LAUNCHER_BIN="$HOME/.local/bin/hermes-hub-stop"
|
||||
if [ -f "$STOP_LAUNCHER_SRC" ]; then
|
||||
cp "$STOP_LAUNCHER_SRC" "$STOP_LAUNCHER_BIN"
|
||||
chmod +x "$STOP_LAUNCHER_BIN"
|
||||
cp "$SCRIPT_DIR/lib_stop_running_hub.sh" "$HOME/.local/bin/lib_stop_running_hub.sh"
|
||||
fi
|
||||
|
||||
# Create .desktop file
|
||||
#
|
||||
# Иконка — PNG, не .ico. Измерено на настоящем GTK-рабочем столе:
|
||||
# GdkPixbuf.Pixbuf.new_from_file на HermesHub.ico падает с "Compressed icons
|
||||
# are not supported", а .desktop-файл с несуществующей или неподдерживаемой
|
||||
# иконкой Nautilus и меню приложений просто показывают пустое место — без
|
||||
# ошибки, молча. Значок был бы вечно пустым на любом GTK-окружении (GNOME,
|
||||
# большинство производных). PNG в тех же ассетах уже есть и загружается.
|
||||
DESKTOP_FILE="$HOME/.local/share/applications/hermes-hub-web.desktop"
|
||||
ICON_PATH="$HERMES_HOME/plugins/antigravity-provider/assets/branding/app/HermesHub.ico"
|
||||
ICON_PATH="$HERMES_HOME/plugins/antigravity-provider/assets/branding/app/app_icon_256.png"
|
||||
if [ ! -f "$ICON_PATH" ]; then
|
||||
ICON_PATH="utilities-terminal"
|
||||
fi
|
||||
|
|
@ -289,6 +272,28 @@ StartupWMClass=hermes-hub-web
|
|||
EOF
|
||||
|
||||
chmod +x "$DESKTOP_FILE"
|
||||
|
||||
# Второй пункт меню — «Остановить». Terminal=true: без окна владелец не
|
||||
# увидит, остановился ли хаб на самом деле, и не заметит «⚠ Остались
|
||||
# процессы» из lib_stop_running_hub.sh, если что-то пошло не так.
|
||||
if [ -f "$STOP_LAUNCHER_BIN" ]; then
|
||||
STOP_DESKTOP_FILE="$HOME/.local/share/applications/hermes-hub-stop.desktop"
|
||||
cat <<EOF > "$STOP_DESKTOP_FILE"
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=Stop Hermes Hub
|
||||
GenericName=Stop the Hermes Hub background server
|
||||
Comment=Останавливает фоновый сервер Hermes Hub
|
||||
Exec=$STOP_LAUNCHER_BIN
|
||||
Icon=$ICON_PATH
|
||||
Terminal=true
|
||||
Categories=Development;Utility;
|
||||
StartupNotify=false
|
||||
EOF
|
||||
chmod +x "$STOP_DESKTOP_FILE"
|
||||
fi
|
||||
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
|
||||
fi
|
||||
|
|
|
|||
54
installer/lib_stop_running_hub.sh
Normal file
54
installer/lib_stop_running_hub.sh
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# Hermes Hub — общая функция остановки работающего хаба (Linux/POSIX).
|
||||
#
|
||||
# До этого файла одна и та же функция была отдельно вписана в install-linux.sh
|
||||
# и в uninstall-linux.sh — две копии, которые разошлись бы при первой же
|
||||
# правке одной из них незамеченной для другой. Источник источается («source»)
|
||||
# обоими скриптами и лаунчером остановки, поэтому логика одна.
|
||||
#
|
||||
# Использование: `source "$(dirname "$0")/lib_stop_running_hub.sh"`, затем
|
||||
# вызвать `stop_running_hub`. Функция сама печатает ход дела и возвращает
|
||||
# 0 (остановлен или нечего было останавливать) либо 1 (что-то осталось —
|
||||
# вызывающий решает, прерывать ли из-за этого).
|
||||
# ==============================================================================
|
||||
|
||||
stop_running_hub() {
|
||||
local pattern="antigravity_provider.router.web|hermes_hub_web_entry"
|
||||
local pids
|
||||
# Только процессы ЭТОГО пользователя и только те, что относятся к хабу.
|
||||
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '\n' ' ')"
|
||||
|
||||
if [ -z "$pids" ]; then
|
||||
echo " Работающий хаб не найден — останавливать нечего."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo " Найдены процессы хаба: $pids"
|
||||
# shellcheck disable=SC2086
|
||||
kill $pids 2>/dev/null || true
|
||||
|
||||
local waited=0
|
||||
while [ "$waited" -lt 10 ]; do
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '\n' ' ')"
|
||||
[ -z "$pids" ] && break
|
||||
done
|
||||
|
||||
if [ -n "$pids" ]; then
|
||||
echo " Не завершились за 10 секунд, снимаю принудительно: $pids"
|
||||
# shellcheck disable=SC2086
|
||||
kill -9 $pids 2>/dev/null || true
|
||||
sleep 1
|
||||
pids="$(pgrep -u "$(id -u)" -f "$pattern" 2>/dev/null | tr '\n' ' ')"
|
||||
fi
|
||||
|
||||
if [ -n "$pids" ]; then
|
||||
echo " ⚠ Остались процессы: $pids. Снимите их вручную."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo " Хаб остановлен."
|
||||
return 0
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
DEFAULT_HERMES_HOME="$HOME/.hermes"
|
||||
HERMES_HOME="${HERMES_HOME:-$DEFAULT_HERMES_HOME}"
|
||||
|
||||
|
|
@ -23,18 +25,35 @@ echo "======================================================================"
|
|||
echo "Hermes Home : $HERMES_HOME"
|
||||
echo ""
|
||||
|
||||
# 0. Остановка работающего хаба.
|
||||
#
|
||||
# Тот же порядок, что в install-linux.sh, и по той же причине: файлы под
|
||||
# работающим процессом здесь не просто устаревают, а исчезают. С
|
||||
# --purge-user-data это ещё и rm -rf каталогов, на которые у живого процесса
|
||||
# открыты файловые дескрипторы — на Linux это не роняет процесс, но он
|
||||
# продолжает отвечать по старому порту после «успешного» удаления, и
|
||||
# следующая попытка что-то с ним сделать бьётся об уже удалённые файлы.
|
||||
echo "[0/4] Остановка работающего Hermes Hub..."
|
||||
# shellcheck source=./lib_stop_running_hub.sh
|
||||
. "$SCRIPT_DIR/lib_stop_running_hub.sh"
|
||||
stop_running_hub || true
|
||||
echo ""
|
||||
|
||||
# 1. Remove Plugin Integration
|
||||
echo "[1/3] Removing plugin integration..."
|
||||
echo "[1/4] Removing plugin integration..."
|
||||
if [ -d "$HERMES_HOME/plugins/antigravity-provider" ]; then
|
||||
rm -rf "$HERMES_HOME/plugins/antigravity-provider"
|
||||
echo " Removed $HERMES_HOME/plugins/antigravity-provider"
|
||||
fi
|
||||
|
||||
# 2. Remove Launchers and Shortcuts
|
||||
echo "[2/3] Removing application launchers and desktop entries..."
|
||||
echo "[2/4] Removing application launchers and desktop entries..."
|
||||
rm -f "$HOME/.local/bin/hermes-hub-web"
|
||||
rm -f "$HERMES_HOME/bin/hermes-hub-web"
|
||||
rm -f "$HOME/.local/bin/hermes-hub-stop"
|
||||
rm -f "$HOME/.local/bin/lib_stop_running_hub.sh"
|
||||
rm -f "$HOME/.local/share/applications/hermes-hub-web.desktop"
|
||||
rm -f "$HOME/.local/share/applications/hermes-hub-stop.desktop"
|
||||
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
|
||||
|
|
@ -42,17 +61,26 @@ fi
|
|||
|
||||
# 3. User Data Handling
|
||||
if [ "$PURGE_USER_DATA" = "true" ]; then
|
||||
echo "[3/3] Purging user data (--purge-user-data specified)..."
|
||||
echo "[3/4] Purging user data (--purge-user-data specified)..."
|
||||
rm -f "$HERMES_HOME/config/router_profiles.yaml"
|
||||
rm -rf "$HERMES_HOME/agy_profiles"
|
||||
rm -rf "$HERMES_HOME/codex_profiles"
|
||||
rm -rf "$HERMES_HOME/opencode_profiles"
|
||||
echo " User configuration and profiles purged."
|
||||
else
|
||||
echo "[3/3] Preserving user data and credentials."
|
||||
echo "[3/4] Preserving user data and credentials."
|
||||
echo " Your router profiles, auth keys, and settings in $HERMES_HOME remain intact."
|
||||
fi
|
||||
|
||||
# 4. Post-uninstall verification: пойманный хаб действительно молчит.
|
||||
echo "[4/4] Verifying no hub process remains..."
|
||||
REMAINING="$(pgrep -u "$(id -u)" -f "antigravity_provider.router.web|hermes_hub_web_entry" 2>/dev/null | tr '\n' ' ')"
|
||||
if [ -n "$REMAINING" ]; then
|
||||
echo " ⚠ Всё ещё работает: $REMAINING — удаление файлов это не остановило."
|
||||
else
|
||||
echo " Хаб не работает."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "======================================================================"
|
||||
echo " HERMES HUB UNINSTALLED SUCCESSFULLY FROM LINUX "
|
||||
|
|
|
|||
49
launcher/hermes-hub-stop.sh
Normal file
49
launcher/hermes-hub-stop.sh
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# Hermes Hub — Stop (Linux)
|
||||
#
|
||||
# На Windows фоновый сервер запускается из HermesHubWeb.exe, который держит
|
||||
# значок в системном трее — оттуда «Exit» останавливает процесс. На Linux
|
||||
# сервер стартует через nohup и остаётся в фоне после закрытия окна браузера
|
||||
# (так и задумано: не переустанавливать при каждом перезапуске окна), но
|
||||
# остановить его после этого было решительно нечем — ни кнопки в интерфейсе
|
||||
# (её нет ни на одной платформе), ни трея, ни пункта меню. Только терминал и
|
||||
# pkill вручную, либо переустановка/удаление, которые останавливают хаб
|
||||
# только как побочный эффект.
|
||||
#
|
||||
# Этот скрипт — тот недостающий эквивалент «Exit из трея»: доступен из меню
|
||||
# приложений через собственный .desktop-пункт, использует ту же проверенную
|
||||
# функцию остановки, что installer/install-linux.sh и uninstall-linux.sh.
|
||||
# ==============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Устанавливается рядом (в ~/.hermes/bin) install-linux.sh — оттуда и берём
|
||||
# общую функцию. Если скрипт запущен не из установленного места (например,
|
||||
# прямо из репозитория), ищем installer/ на уровень выше.
|
||||
LIB=""
|
||||
for candidate in \
|
||||
"$SCRIPT_DIR/lib_stop_running_hub.sh" \
|
||||
"$SCRIPT_DIR/../installer/lib_stop_running_hub.sh"
|
||||
do
|
||||
if [ -f "$candidate" ]; then
|
||||
LIB="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$LIB" ]; then
|
||||
echo "❌ Не найдена installer/lib_stop_running_hub.sh — переустановите Hermes Hub." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck source=../installer/lib_stop_running_hub.sh
|
||||
. "$LIB"
|
||||
|
||||
echo "Останавливаю Hermes Hub..."
|
||||
if stop_running_hub; then
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
|
|
@ -23,9 +23,14 @@ ROOT = Path(__file__).resolve().parent.parent
|
|||
if str(ROOT / "src") not in sys.path:
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from antigravity_provider.console_encoding import force_utf8_output
|
||||
from antigravity_provider.version import __version__, get_version
|
||||
from antigravity_provider import paths
|
||||
|
||||
# Отчёт ворот печатается по-русски, а консоль Windows-раннера — cp1252.
|
||||
# Ставится до первого вывода: иначе падает вывод, а не проверки.
|
||||
force_utf8_output()
|
||||
|
||||
|
||||
def check_version_consistency() -> tuple[bool, str]:
|
||||
ver = get_version()
|
||||
|
|
@ -204,88 +209,265 @@ def check_security_zero_secrets() -> tuple[bool, str]:
|
|||
return True, "Zero secret files, live tokens, or obfuscated secret assignments in src/"
|
||||
|
||||
|
||||
def check_production_update_feed() -> tuple[bool, str]:
|
||||
"""Live verification of public release feed manifest and package URL."""
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Publication Gate
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Проверка публикации отделена от офлайновой части, потому что раньше они были
|
||||
# смешаны и обе были беззубыми. Измерено на прежней реализации:
|
||||
# - при полном обрыве сети возвращался PASS ("check skipped");
|
||||
# - при 404 на манифест возвращался PASS ("not yet published");
|
||||
# - при 404 на пакет возвращался PASS ("pending upload");
|
||||
# - при живом пакете печаталось PACKAGE_HASH_VERIFIED=True, хотя hashlib в
|
||||
# файле не вызывался ни разу: скачивались байты 0-10 через заголовок Range,
|
||||
# и этого хватало, чтобы объявить хеш проверенным.
|
||||
# То есть ворота публикации пропускали релиз при любом исходе, включая полное
|
||||
# отсутствие релиза.
|
||||
#
|
||||
# Теперь: офлайновые проверки (1-6) блокируют всегда; публикация проверяется
|
||||
# по-настоящему — релиз есть, ассеты есть, пакет скачан целиком, SHA-256
|
||||
# сошёлся с опубликованным. Блокирует она в режиме публикации (--publication
|
||||
# или HERMES_RELEASE_PUBLICATION_GATE=1); в обычном прогоне CI, где релиза для
|
||||
# ветки нет и быть не должно, результат сообщается как есть и не блокирует.
|
||||
# Неизмеренное называется "Н/Д" с причиной, а не выдаётся за проверенное.
|
||||
|
||||
PUBLICATION_MODE_ENV = "HERMES_RELEASE_PUBLICATION_GATE"
|
||||
|
||||
# Имена ассетов-установщиков; совпадают с выбором в update_manager.
|
||||
PACKAGE_ASSET_NAMES = ("HermesHubSetup.exe", "hermes-hub-setup.sh", "install-linux.sh")
|
||||
CHECKSUMS_ASSET_NAME = "checksums.txt"
|
||||
|
||||
# Пакет качается целиком, поэтому размер ограничен: подставленный гигантский
|
||||
# ассет не должен превращать ворота в отказ в обслуживании самим себе.
|
||||
MAX_PACKAGE_BYTES = 512 * 1024 * 1024
|
||||
|
||||
# Нижняя граница размера установщика — защита от усечённой сборки. Найдено
|
||||
# живым прогоном на Windows (A61): собранный HermesHubSetup.exe считался
|
||||
# готовым к публикации даже будучи почти пустым — сборка прервалась, а файл
|
||||
# остался. 1 МБ — заведомо меньше любого настоящего установщика (несёт
|
||||
# исходники плагина вшитым ресурсом), но отличает пустышку от файла.
|
||||
MIN_PACKAGE_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
def is_publication_mode() -> bool:
|
||||
"""Требуется ли блокирующая проверка публикации."""
|
||||
return "--publication" in sys.argv or os.environ.get(PUBLICATION_MODE_ENV, "") == "1"
|
||||
|
||||
|
||||
def _http_get(url: str, timeout: int = 30):
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": f"HermesHub-ReleaseGate/{__version__}"}
|
||||
)
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
|
||||
|
||||
def _download_and_hash(url: str) -> tuple[str, int]:
|
||||
"""Скачать поток целиком и посчитать SHA-256. Никаких частичных диапазонов."""
|
||||
import hashlib
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
with _http_get(url, timeout=120) as resp:
|
||||
while True:
|
||||
chunk = resp.read(1024 * 256)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
if size > MAX_PACKAGE_BYTES:
|
||||
raise ValueError(f"пакет превышает {MAX_PACKAGE_BYTES} байт")
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest(), size
|
||||
|
||||
|
||||
def _hash_local_file(path: Path) -> tuple[str, int]:
|
||||
"""Посчитать SHA-256 локального файла целиком. Размер — побочный продукт."""
|
||||
import hashlib
|
||||
digest = hashlib.sha256()
|
||||
size = 0
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(1024 * 256)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest(), size
|
||||
|
||||
|
||||
def _parse_checksums(text: str) -> dict[str, str]:
|
||||
"""Разобрать строки вида '<sha256> <имя файла>'."""
|
||||
table: dict[str, str] = {}
|
||||
for line in text.splitlines():
|
||||
parts = line.strip().split()
|
||||
if len(parts) >= 2 and re.fullmatch(r"[0-9a-fA-F]{64}", parts[0]):
|
||||
table[parts[-1].lstrip("*")] = parts[0].lower()
|
||||
return table
|
||||
|
||||
|
||||
def check_offline_update_contract() -> tuple[bool, str]:
|
||||
"""Офлайновая часть: адрес обновления входит в список разрешённых."""
|
||||
from antigravity_provider.updater.update_manager import DEFAULT_UPDATE_URL, is_allowed_update_host
|
||||
|
||||
if not is_allowed_update_host(DEFAULT_UPDATE_URL):
|
||||
return False, f"Default update URL host not in allowlist: {DEFAULT_UPDATE_URL}"
|
||||
return False, f"Адрес обновления вне списка разрешённых: {DEFAULT_UPDATE_URL}"
|
||||
return True, f"Адрес обновления в списке разрешённых: {DEFAULT_UPDATE_URL}"
|
||||
|
||||
|
||||
def check_publication_gate() -> tuple[bool, str]:
|
||||
"""Релиз опубликован, ассеты на месте, SHA-256 пакета сошёлся.
|
||||
|
||||
В режиме публикации любой недостижимый шаг — отказ. Вне его отказ не
|
||||
блокирует релиз, но и не выдаётся за успех.
|
||||
"""
|
||||
import urllib.error
|
||||
from antigravity_provider.updater.update_manager import DEFAULT_UPDATE_URL
|
||||
|
||||
blocking = is_publication_mode()
|
||||
|
||||
def verdict(ok: bool, msg: str) -> tuple[bool, str]:
|
||||
if ok:
|
||||
return True, msg
|
||||
if blocking:
|
||||
return False, msg
|
||||
return True, f"[НЕ БЛОКИРУЕТ: режим публикации не запрошен] {msg}"
|
||||
|
||||
# 1. Манифест релиза
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
DEFAULT_UPDATE_URL,
|
||||
headers={"User-Agent": f"HermesHub-ReleaseGate/{__version__}"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=6) as resp:
|
||||
if resp.status == 200:
|
||||
with _http_get(DEFAULT_UPDATE_URL) as resp:
|
||||
if resp.status != 200:
|
||||
return verdict(False, f"Манифест релиза ответил HTTP {resp.status}")
|
||||
data = json.loads(resp.read().decode("utf-8-sig"))
|
||||
p_ver = data.get("version") or data.get("tag_name", "").lstrip("v")
|
||||
p_url = data.get("package_url")
|
||||
if not p_url and data.get("assets"):
|
||||
p_url = data["assets"][0].get("browser_download_url")
|
||||
if not p_url:
|
||||
p_url = data.get("html_url") or DEFAULT_UPDATE_URL
|
||||
|
||||
if not p_ver:
|
||||
return False, "Public update manifest is missing version or tag_name"
|
||||
|
||||
# Verify package URL reachability
|
||||
pkg_live = False
|
||||
pkg_status = "UNKNOWN"
|
||||
try:
|
||||
head_req = urllib.request.Request(
|
||||
p_url,
|
||||
headers={"User-Agent": f"HermesHub-ReleaseGate/{__version__}"}
|
||||
)
|
||||
# Use Range header to avoid downloading huge binaries
|
||||
head_req.add_header("Range", "bytes=0-10")
|
||||
with urllib.request.urlopen(head_req, timeout=6) as pkg_resp:
|
||||
if pkg_resp.status in (200, 206, 302):
|
||||
pkg_live = True
|
||||
pkg_status = "PACKAGE_LIVE"
|
||||
except urllib.error.HTTPError as pkg_he:
|
||||
if pkg_he.code == 404:
|
||||
pkg_status = "PENDING_RELEASE_UPLOAD_404"
|
||||
else:
|
||||
pkg_status = f"HTTP_{pkg_he.code}"
|
||||
except Exception as pkg_ex:
|
||||
pkg_status = f"CHECK_SKIPPED_{pkg_ex}"
|
||||
|
||||
manifest_live = True
|
||||
package_live = False
|
||||
hash_verified = False
|
||||
|
||||
if pkg_live:
|
||||
package_live = True
|
||||
# If package is live, verify hash on partial bytes or full stream
|
||||
hash_verified = True
|
||||
return True, f"[MANIFEST_LIVE=True, PACKAGE_LIVE=True, PACKAGE_HASH_VERIFIED=True] Manifest live (v{p_ver}) and release asset verified at {p_url}"
|
||||
elif pkg_status == "PENDING_RELEASE_UPLOAD_404":
|
||||
return True, (
|
||||
f"[MANIFEST_LIVE=True, PACKAGE_LIVE=False (Pending Upload 404), PACKAGE_HASH_VERIFIED=Offline Validated] "
|
||||
f"Manifest is live (v{p_ver}), release zip ready for GitHub Release asset upload. Offline updater tests passed."
|
||||
)
|
||||
else:
|
||||
return True, (
|
||||
f"[MANIFEST_LIVE=True, PACKAGE_LIVE=False ({pkg_status}), PACKAGE_HASH_VERIFIED=Offline Validated] "
|
||||
f"Manifest live (v{p_ver}). Offline updater tests passed."
|
||||
)
|
||||
|
||||
except urllib.error.HTTPError as he:
|
||||
if he.code == 404:
|
||||
return True, f"[MANIFEST_LIVE=False, PACKAGE_LIVE=False] Public manifest not yet published (HTTP 404). Offline updater tests passed."
|
||||
return False, f"HTTP Error checking update feed: {he}"
|
||||
return verdict(False, f"Манифест релиза недоступен: HTTP {he.code} ({DEFAULT_UPDATE_URL})")
|
||||
except Exception as exc:
|
||||
return True, f"[MANIFEST_LIVE=Unknown, PACKAGE_LIVE=Unknown] Public feed check skipped ({exc}). Offline updater tests passed."
|
||||
return verdict(False, f"Манифест релиза недоступен: {type(exc).__name__}: {exc}")
|
||||
|
||||
return True, "Production update feed verified"
|
||||
version = data.get("version") or str(data.get("tag_name", "")).lstrip("v")
|
||||
if not version:
|
||||
return verdict(False, "В манифесте релиза нет ни version, ни tag_name")
|
||||
|
||||
# 2. Ассеты
|
||||
assets: dict[str, str] = {}
|
||||
for asset in data.get("assets") or []:
|
||||
name = asset.get("name")
|
||||
url = asset.get("browser_download_url")
|
||||
if name and url:
|
||||
assets[name] = url
|
||||
if not assets and data.get("package_url"):
|
||||
assets[Path(data["package_url"]).name] = data["package_url"]
|
||||
|
||||
if not assets:
|
||||
return verdict(False, f"У релиза v{version} нет ни одного ассета")
|
||||
|
||||
packages = [n for n in PACKAGE_ASSET_NAMES if n in assets]
|
||||
if not packages:
|
||||
return verdict(
|
||||
False,
|
||||
f"У релиза v{version} нет ни одного пакета установки "
|
||||
f"{PACKAGE_ASSET_NAMES}; опубликованы: {sorted(assets)}",
|
||||
)
|
||||
|
||||
# 3. Опубликованные контрольные суммы
|
||||
if CHECKSUMS_ASSET_NAME not in assets:
|
||||
return verdict(False, f"У релиза v{version} нет {CHECKSUMS_ASSET_NAME}: сверять хеш не с чем")
|
||||
try:
|
||||
with _http_get(assets[CHECKSUMS_ASSET_NAME]) as resp:
|
||||
published = _parse_checksums(resp.read().decode("utf-8", errors="replace"))
|
||||
except Exception as exc:
|
||||
return verdict(False, f"{CHECKSUMS_ASSET_NAME} не скачивается: {type(exc).__name__}: {exc}")
|
||||
if not published:
|
||||
return verdict(False, f"{CHECKSUMS_ASSET_NAME} не содержит ни одной строки с SHA-256")
|
||||
|
||||
# 4. Полное скачивание и сверка хеша каждого пакета
|
||||
verified = []
|
||||
for name in packages:
|
||||
expected = published.get(name)
|
||||
if not expected:
|
||||
return verdict(False, f"Для {name} нет строки в {CHECKSUMS_ASSET_NAME}")
|
||||
try:
|
||||
actual, size = _download_and_hash(assets[name])
|
||||
except Exception as exc:
|
||||
return verdict(False, f"{name} не скачивается целиком: {type(exc).__name__}: {exc}")
|
||||
if actual != expected:
|
||||
return verdict(False, f"SHA-256 {name} не сошёлся: опубликован {expected}, посчитан {actual}")
|
||||
verified.append(f"{name} ({size} байт)")
|
||||
|
||||
return True, (
|
||||
f"[RELEASE_LIVE=True, PACKAGES={len(verified)}, PACKAGE_HASH_VERIFIED=True] "
|
||||
f"Релиз v{version}: пакеты скачаны целиком и сверены с {CHECKSUMS_ASSET_NAME} — "
|
||||
+ ", ".join(verified)
|
||||
)
|
||||
|
||||
|
||||
def check_publishable_assets(dist_dir: Path) -> tuple[bool, str]:
|
||||
"""Собранный набор ассетов действительно устанавливается обновлением.
|
||||
|
||||
Проверяется до публикации. Причина: update_manager ищет в релизе строго
|
||||
HermesHubSetup.exe или hermes-hub-setup.sh/install-linux.sh, а release.yml
|
||||
собирает hermes-hub-<версия>.zip и update_manifest.json. Такой релиз
|
||||
становится "latest", и на любой попытке обновиться владелец получает
|
||||
"В релизе не найден подходящий файл обновления для текущей платформы".
|
||||
|
||||
Раньше это не проявлялось лишь потому, что весь релизный конвейер падал
|
||||
на тех же двух дефектах, что и CI: каждый его прогон завершался ошибкой, а
|
||||
релизы публиковались мимо него. Как только тесты позеленели, случайная
|
||||
защита исчезла — поэтому набор проверяется явно.
|
||||
|
||||
Помимо присутствия файлов — размер и хеш КАЖДОГО найденного установщика
|
||||
против локального checksums.txt. Найдено живым прогоном на Windows
|
||||
(A61): сборка может прерваться на середине и оставить усечённый файл, а
|
||||
checksums.txt и сам установщик могут разойтись ещё до всякой публикации.
|
||||
Проверка одного присутствия этого не ловит.
|
||||
"""
|
||||
if not dist_dir.is_dir():
|
||||
return False, f"Каталог сборки не найден: {dist_dir}"
|
||||
|
||||
present = {item.name for item in dist_dir.iterdir() if item.is_file()}
|
||||
installers = sorted(present & set(PACKAGE_ASSET_NAMES))
|
||||
problems = []
|
||||
if not installers:
|
||||
problems.append(
|
||||
f"нет ни одного установщика {list(PACKAGE_ASSET_NAMES)} — "
|
||||
f"обновление такой релиз поставить не сможет"
|
||||
)
|
||||
if CHECKSUMS_ASSET_NAME not in present:
|
||||
problems.append(f"нет {CHECKSUMS_ASSET_NAME} — сверять хеш пакета будет не с чем")
|
||||
|
||||
if problems:
|
||||
return False, (
|
||||
f"Набор ассетов в {dist_dir} непригоден для публикации: "
|
||||
+ "; ".join(problems)
|
||||
+ f". Собрано: {sorted(present)}. Установщики собираются скриптами "
|
||||
f"installer/build_installer.ps1 и installer/build_installer_linux.sh"
|
||||
)
|
||||
|
||||
local_checksums = _parse_checksums((dist_dir / CHECKSUMS_ASSET_NAME).read_text(encoding="utf-8-sig", errors="replace"))
|
||||
verified = []
|
||||
for name in installers:
|
||||
actual_hash, size = _hash_local_file(dist_dir / name)
|
||||
if size < MIN_PACKAGE_BYTES:
|
||||
return False, (
|
||||
f"{name} подозрительно мал ({size} байт, ожидался хотя бы {MIN_PACKAGE_BYTES}) "
|
||||
f"— похоже на прерванную сборку"
|
||||
)
|
||||
expected_hash = local_checksums.get(name)
|
||||
if not expected_hash:
|
||||
return False, f"Для {name} нет строки в {CHECKSUMS_ASSET_NAME} — сверить хеш не с чем"
|
||||
if actual_hash != expected_hash:
|
||||
return False, (
|
||||
f"SHA-256 {name} не сошёлся с {CHECKSUMS_ASSET_NAME}: "
|
||||
f"файл {actual_hash}, записан {expected_hash}"
|
||||
)
|
||||
verified.append(f"{name} ({size} байт, SHA-256 сошёлся)")
|
||||
|
||||
return True, f"Набор ассетов пригоден для публикации: {', '.join(verified)}"
|
||||
|
||||
|
||||
def run_release_gate():
|
||||
print("=" * 70)
|
||||
print(f" Hermes Hub — Release Gate Verification Suite (Target: v{__version__})")
|
||||
mode = "публикация (проверки 1-8 блокируют)" if is_publication_mode() else "офлайн (блокируют 1-7)"
|
||||
print(f" Режим: {mode}")
|
||||
print("=" * 70)
|
||||
|
||||
checks = [
|
||||
|
|
@ -295,7 +477,8 @@ def run_release_gate():
|
|||
("4. Full Offline Pytest Suite", "[INTEGRATION VERIFIED]", check_full_test_suite),
|
||||
("5. Zero Hardcoded Developer Paths", "[STATIC VERIFIED]", check_zero_hardcoded_paths),
|
||||
("6. Zero Credentials & AST Secret Scan", "[SECURITY VERIFIED]", check_security_zero_secrets),
|
||||
("7. Public Production Update Feed", "[LIVE STATUS]", check_production_update_feed),
|
||||
("7. Update Contract (offline)", "[STATIC VERIFIED]", check_offline_update_contract),
|
||||
("8. Publication Gate", "[LIVE VERIFIED]", check_publication_gate),
|
||||
]
|
||||
|
||||
all_passed = True
|
||||
|
|
@ -319,5 +502,24 @@ def run_release_gate():
|
|||
sys.exit(1)
|
||||
|
||||
|
||||
def _run_single(title: str, check) -> None:
|
||||
"""Выполнить одну проверку и завершиться её итогом."""
|
||||
print("=" * 70)
|
||||
print(f" Hermes Hub — {title}")
|
||||
print("=" * 70)
|
||||
ok, msg = check()
|
||||
print(f" {'[OK]' if ok else '[FAIL]'} {msg}")
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--assets" in sys.argv:
|
||||
index = sys.argv.index("--assets")
|
||||
target = Path(sys.argv[index + 1]) if len(sys.argv) > index + 1 else ROOT / "dist"
|
||||
_run_single("Publishable Assets Check", lambda: check_publishable_assets(target))
|
||||
elif "--publication-only" in sys.argv:
|
||||
# Запускается ПОСЛЕ публикации: проверяет опубликованный релиз, а не сборку.
|
||||
os.environ[PUBLICATION_MODE_ENV] = "1"
|
||||
_run_single("Publication Gate", check_publication_gate)
|
||||
else:
|
||||
run_release_gate()
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ for p in [
|
|||
if p.is_dir() and str(p) not in sys.path:
|
||||
sys.path.insert(0, str(p))
|
||||
|
||||
from antigravity_provider.console_encoding import force_utf8_output
|
||||
|
||||
# Отчёт печатается по-русски, а консоль Windows-раннера в CI — cp1252: без этого
|
||||
# первый же [PASS] с кириллицей роняет скрипт UnicodeEncodeError'ом ещё до того,
|
||||
# как проверки что-либо покажут. Ставится до первого вывода.
|
||||
force_utf8_output()
|
||||
|
||||
from antigravity_provider.router.router_config import (
|
||||
RolePolicy,
|
||||
RouterConfig,
|
||||
|
|
@ -141,8 +148,15 @@ def run_checks() -> int:
|
|||
# Проверяем изоляцию пути, а не побочное создание каталога: запрос пути
|
||||
# каталогов больше не плодит, иначе любая проверка засоряла бы диск
|
||||
# десятком пустых слотов.
|
||||
pdir = get_profile_env_dir("ag-w2")
|
||||
assert "ag-w2" in str(pdir)
|
||||
#
|
||||
# ID заведомо не боевой. Было "ag-w2" — на живой машине владельца это
|
||||
# существующий подключённый профиль, и "assert not pdir.exists()" падал
|
||||
# не из-за бага, а потому что каталог реального аккаунта и так был на
|
||||
# месте. Найдено прогоном на настоящей установке (A61): скрипт возвращал
|
||||
# код 12, хотя изоляция путей работала верно.
|
||||
probe_id = "ag-probe-isolation-test"
|
||||
pdir = get_profile_env_dir(probe_id)
|
||||
assert probe_id in str(pdir)
|
||||
assert "agy_profiles" in str(pdir)
|
||||
assert not pdir.exists(), "запрос пути не должен создавать каталог"
|
||||
print(f" [PASS] Profile directory isolated at {pdir} (не создан)")
|
||||
|
|
|
|||
48
src/antigravity_provider/console_encoding.py
Normal file
48
src/antigravity_provider/console_encoding.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Принудительный UTF-8 для потоков вывода.
|
||||
|
||||
Инструменты Hermes печатают по-русски, а консоль Windows-раннера в CI работает
|
||||
в cp1252. Первый же `print` с кириллицей роняет процесс UnicodeEncodeError'ом —
|
||||
падает вывод, не логика. Измерено на `scripts/verify_multi_provider_router.py`:
|
||||
строка `[PASS] Чистая конфигурация...` обрывала прогон с кодом 1.
|
||||
|
||||
Модуль ставит UTF-8 на stdout/stderr и оставляет запасной путь на случай, когда
|
||||
перекодировать поток нельзя: тогда непечатаемые символы заменяются, но процесс
|
||||
продолжает работу. Вывод инструмента не должен быть причиной падения.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any, Iterable
|
||||
|
||||
__all__ = ["force_utf8_output"]
|
||||
|
||||
|
||||
def _reconfigure(stream: Any) -> bool:
|
||||
"""Перевести один поток на UTF-8. True, если получилось."""
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if reconfigure is None:
|
||||
return False
|
||||
for errors in ("strict", "backslashreplace"):
|
||||
try:
|
||||
reconfigure(encoding="utf-8", errors=errors)
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
# Поток не перекодировать (подменён, закрыт, не текстовый). Тогда хотя бы
|
||||
# снимем строгость с текущей кодировки, чтобы кириллица не роняла процесс.
|
||||
try:
|
||||
reconfigure(errors="backslashreplace")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def force_utf8_output(streams: Iterable[str] = ("stdout", "stderr")) -> None:
|
||||
"""Перевести стандартные потоки на UTF-8; молча пропустить недоступные.
|
||||
|
||||
Вызывается один раз на старте точки входа, до первого вывода.
|
||||
"""
|
||||
for name in streams:
|
||||
stream = getattr(sys, name, None)
|
||||
if stream is not None:
|
||||
_reconfigure(stream)
|
||||
|
|
@ -9,17 +9,16 @@ import time
|
|||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from antigravity_provider.console_encoding import force_utf8_output
|
||||
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, load_router_config
|
||||
from antigravity_provider.router.health_tracker import HealthTracker
|
||||
from antigravity_provider.router.router_engine import RouterEngine, get_router_engine
|
||||
from antigravity_provider.router.adapters import get_adapter
|
||||
from antigravity_provider.router.profile_manager import ProfileAuthManager, mask_email, mask_id
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
# Вывод CLI — по-русски, консоль Windows по умолчанию не UTF-8. Общий помощник,
|
||||
# тот же, что и у скриптов проверки: одна реализация, одно поведение.
|
||||
force_utf8_output()
|
||||
|
||||
|
||||
def print_router_status() -> int:
|
||||
|
|
|
|||
|
|
@ -135,6 +135,117 @@ DESTRUCTIVE_COMMAND_NAMES: Set[str] = {
|
|||
}
|
||||
|
||||
|
||||
# ── Единый конвейер разбора пути ────────────────────────────────
|
||||
#
|
||||
# Граница вокруг агентских shell-действий обязана работать одинаково на всех
|
||||
# поддерживаемых системах, иначе доказанной она не является ни на одной.
|
||||
# Измерено: "rm -rf $HOME/.hermes" отклонялось на Linux и проходило на Windows,
|
||||
# потому что переменной HOME в окружении Windows нет — os.path.expandvars
|
||||
# оставлял "$HOME" как есть, путь переставал быть абсолютным, склеивался с
|
||||
# каталогом проекта и оказывался "внутри разрешённого корня". Зеркальная дыра
|
||||
# на Linux: "%USERPROFILE%\.hermes" и "C:\Windows" тоже проходили.
|
||||
#
|
||||
# Порядок шагов: классификация диалекта → раскрытие распознанных переменных →
|
||||
# нормализация разделителей → канонизация → сравнение с корнями. Каждый шаг,
|
||||
# который не удался, закрывает проход: непроверяемый путь не считается
|
||||
# разрешённым.
|
||||
|
||||
_HOME_VARIABLE_NAMES = frozenset({"HOME", "USERPROFILE"})
|
||||
|
||||
_VARIABLE_REFERENCE = re.compile(
|
||||
r"\$\{(?P<brace>[A-Za-z_][A-Za-z0-9_]*)\}"
|
||||
r"|\$(?P<bare>[A-Za-z_][A-Za-z0-9_]*)"
|
||||
r"|%(?P<percent>[A-Za-z_][A-Za-z0-9_]*)%"
|
||||
)
|
||||
|
||||
_WINDOWS_DRIVE_PREFIX = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
|
||||
# Признаки записи в диалекте cmd/PowerShell: %VAR%, буква диска, разделитель "\".
|
||||
_WINDOWS_DIALECT_MARKERS = re.compile(r"%[A-Za-z_][A-Za-z0-9_]*%|[A-Za-z]:[\\/]|\\[A-Za-z0-9_.]")
|
||||
|
||||
|
||||
def looks_like_windows_dialect(cmd_line: str) -> bool:
|
||||
"""Записана ли команда в диалекте Windows.
|
||||
|
||||
Диалект определяется по самой строке, а не по системе-хозяину: команду в
|
||||
записи cmd могут прислать и на Linux, и разобрать её posix-правилами нельзя —
|
||||
shlex съест "\" как экранирование и разделитель пути исчезнет.
|
||||
"""
|
||||
return bool(_WINDOWS_DIALECT_MARKERS.search(cmd_line))
|
||||
|
||||
|
||||
def expand_path_argument(raw: str) -> Tuple[str, Optional[str]]:
|
||||
"""Раскрыть "~" и переменные окружения обоих диалектов.
|
||||
|
||||
Возвращает (раскрытая строка, причина нераскрытия | None). Имена HOME и
|
||||
USERPROFILE разрешаются в домашний каталог даже тогда, когда их нет в
|
||||
окружении: команда, написанная в чужом диалекте, целит ровно туда же.
|
||||
Всё, что раскрыть не удалось, возвращается причиной — вызывающий обязан
|
||||
закрыться, а не гадать.
|
||||
"""
|
||||
unresolved: List[str] = []
|
||||
|
||||
def _substitute(match: "re.Match[str]") -> str:
|
||||
name = match.group("brace") or match.group("bare") or match.group("percent")
|
||||
value = os.environ.get(name)
|
||||
if value is None and name.upper() in _HOME_VARIABLE_NAMES:
|
||||
try:
|
||||
value = str(Path.home())
|
||||
except Exception:
|
||||
value = None
|
||||
if value is None:
|
||||
unresolved.append(name)
|
||||
return match.group(0)
|
||||
return value
|
||||
|
||||
expanded = _VARIABLE_REFERENCE.sub(_substitute, raw)
|
||||
expanded = os.path.expanduser(expanded)
|
||||
if expanded.startswith("~"):
|
||||
# expanduser не смог определить домашний каталог: оставлять "~" внутри
|
||||
# пути нельзя — он перестанет быть абсолютным и уедет внутрь проекта.
|
||||
unresolved.append("~")
|
||||
|
||||
if unresolved:
|
||||
return expanded, "не раскрыты: " + ", ".join(sorted(set(unresolved)))
|
||||
return expanded, None
|
||||
|
||||
|
||||
def canonical_path(raw: Path | str, base_cwd: Optional[Path | str] = None) -> Tuple[Optional[Path], Optional[str]]:
|
||||
"""Привести аргумент к каноническому пути или объяснить отказ.
|
||||
|
||||
Ровно одно из двух возвращаемых значений не None. Отказ — это отказ в
|
||||
доступе: путь, который нельзя достоверно разрешить, нельзя и признать
|
||||
находящимся внутри разрешённого корня.
|
||||
"""
|
||||
text = str(raw)
|
||||
if not text:
|
||||
return None, "пустой путь"
|
||||
|
||||
expanded, unresolved_reason = expand_path_argument(text)
|
||||
if unresolved_reason:
|
||||
return None, f"путь '{raw}' невозможно раскрыть ({unresolved_reason})"
|
||||
|
||||
normalized = expanded.replace("\\", "/")
|
||||
|
||||
if _WINDOWS_DRIVE_PREFIX.match(normalized) and not Path(normalized).is_absolute():
|
||||
# Путь с буквой диска на не-Windows: канонизировать его нечем. resolve()
|
||||
# припишет ему текущий каталог, и "C:/Windows" окажется внутри проекта.
|
||||
return None, f"путь '{raw}' записан в диалекте другой системы и здесь не проверяем"
|
||||
|
||||
candidate = Path(normalized)
|
||||
if not candidate.is_absolute() and not normalized.startswith("/"):
|
||||
try:
|
||||
base = Path(base_cwd) if base_cwd is not None else paths.get_repo_root()
|
||||
except Exception as exc:
|
||||
return None, f"не определён базовый каталог для '{raw}': {exc}"
|
||||
candidate = base / normalized
|
||||
|
||||
try:
|
||||
return candidate.resolve(), None
|
||||
except Exception as exc:
|
||||
return None, f"путь '{raw}' не разрешается: {exc}"
|
||||
|
||||
|
||||
class WorkspaceBoundaryGuard:
|
||||
"""Enforces explicit workspace boundaries, defends credential directories, and inspects destructive operations."""
|
||||
|
||||
|
|
@ -206,7 +317,11 @@ class WorkspaceBoundaryGuard:
|
|||
def is_inside_allowed_root(self, path: Path | str) -> bool:
|
||||
"""Check whether the given path resolves within any allowed root."""
|
||||
try:
|
||||
target = Path(path).expanduser().resolve()
|
||||
target, resolve_error = canonical_path(path)
|
||||
if target is None:
|
||||
# Путь не разрешается — считать его находящимся внутри
|
||||
# разрешённого корня нельзя.
|
||||
return False
|
||||
for root in self.get_allowed_roots():
|
||||
try:
|
||||
target.relative_to(root)
|
||||
|
|
@ -220,7 +335,9 @@ class WorkspaceBoundaryGuard:
|
|||
def is_forbidden_path(self, path: Path | str) -> Tuple[bool, Optional[str]]:
|
||||
"""Check whether the path touches an unconditionally protected directory or file."""
|
||||
try:
|
||||
target = Path(path).expanduser().resolve()
|
||||
target, resolve_error = canonical_path(path)
|
||||
if target is None:
|
||||
return True, resolve_error
|
||||
# 1. Exact match or child of forbidden directory
|
||||
for fpath in self.get_forbidden_paths():
|
||||
if target == fpath:
|
||||
|
|
@ -252,10 +369,9 @@ class WorkspaceBoundaryGuard:
|
|||
|
||||
Returns: (is_allowed: bool, reason: str, safe_alternative: Optional[str])
|
||||
"""
|
||||
try:
|
||||
target = Path(path).expanduser().resolve()
|
||||
except Exception as exc:
|
||||
return False, f"Недопустимый путь '{path}': {exc}", "Используйте стандартный относительный путь"
|
||||
target, resolve_error = canonical_path(path)
|
||||
if target is None:
|
||||
return False, f"Недопустимый путь: {resolve_error}", "Укажите путь внутри проекта явно, без нераскрытых переменных"
|
||||
|
||||
# Check unconditional forbidden paths for mutating/deleting operations
|
||||
if operation in {"delete", "write", "truncate", "move"}:
|
||||
|
|
@ -290,12 +406,18 @@ class WorkspaceBoundaryGuard:
|
|||
# Parse command tokens
|
||||
if isinstance(cmd_line, list):
|
||||
tokens = list(cmd_line)
|
||||
windows_dialect = any(looks_like_windows_dialect(str(t)) for t in tokens)
|
||||
else:
|
||||
# Диалект берётся из самой команды: строку в записи cmd нельзя
|
||||
# разбирать posix-правилами — shlex съест "\\" как экранирование,
|
||||
# и разделитель пути исчезнет ещё до проверки.
|
||||
windows_dialect = looks_like_windows_dialect(cmd_line)
|
||||
try:
|
||||
# Windows and POSIX-compatible shlex split
|
||||
tokens = shlex.split(cmd_line, posix=(os.name != "nt"))
|
||||
tokens = shlex.split(cmd_line, posix=not windows_dialect)
|
||||
except Exception:
|
||||
tokens = cmd_line.split()
|
||||
if windows_dialect:
|
||||
tokens = [t[1:-1] if len(t) > 1 and t[0] == t[-1] and t[0] in "\"'" else t for t in tokens]
|
||||
|
||||
if not tokens:
|
||||
return True, "OK", None
|
||||
|
|
@ -310,7 +432,7 @@ class WorkspaceBoundaryGuard:
|
|||
# Extract target arguments (skip flags starting with - or /)
|
||||
targets = []
|
||||
for arg in tokens[1:]:
|
||||
if arg.startswith("-") or (os.name == "nt" and arg.startswith("/") and len(arg) == 2):
|
||||
if arg.startswith("-") or (windows_dialect and arg.startswith("/") and len(arg) == 2):
|
||||
continue
|
||||
targets.append(arg)
|
||||
|
||||
|
|
@ -321,17 +443,23 @@ class WorkspaceBoundaryGuard:
|
|||
return False, f"Команда {cmd_name} запущена в недопустимом каталоге: {reason}", alt
|
||||
else:
|
||||
for target_arg in targets:
|
||||
# Тильда и переменные окружения раскрываются ДО проверки.
|
||||
# Тильда и переменные окружения раскрываются ДО проверки, и
|
||||
# одинаково для обоих диалектов.
|
||||
#
|
||||
# Без этого "rm -rf ~/.hermes/agy_profiles" не считался
|
||||
# абсолютным путём, склеивался с каталогом проекта в путь с
|
||||
# буквальным "~" внутри и признавался допустимым. Проверено:
|
||||
# команда с тильдой проходила, та же команда с абсолютным
|
||||
# путём отклонялась. То есть самый естественный способ
|
||||
# написать опасную команду обходил защиту ровно там, ради
|
||||
# чего она и делалась — на каталоге учётных данных.
|
||||
expanded = os.path.expandvars(os.path.expanduser(target_arg))
|
||||
target_path = Path(expanded) if Path(expanded).is_absolute() else (base_cwd / expanded)
|
||||
# буквальным "~" внутри и признавался допустимым; а
|
||||
# "rm -rf $HOME/.hermes" ровно так же проходил на Windows,
|
||||
# где переменной HOME в окружении нет. Самый естественный
|
||||
# способ написать опасную команду обходил защиту ровно там,
|
||||
# ради чего она и делалась — на каталоге учётных данных.
|
||||
target_path, resolve_error = canonical_path(target_arg, base_cwd=base_cwd)
|
||||
if target_path is None:
|
||||
return (
|
||||
False,
|
||||
f"Команда '{cmd_name}' обращается к непроверяемому пути: {resolve_error}",
|
||||
"Укажите путь внутри проекта явно, без нераскрытых переменных",
|
||||
)
|
||||
ok, reason, alt = self.validate_path(target_path, operation="delete")
|
||||
if not ok:
|
||||
return False, f"Команда '{cmd_name}' пытается удалить недопустимый путь '{target_arg}': {reason}", alt
|
||||
|
|
|
|||
|
|
@ -161,7 +161,11 @@ class TelemetryService:
|
|||
if p.is_file():
|
||||
try:
|
||||
import yaml
|
||||
data = yaml.safe_dump(p.read_text(encoding="utf-8"))
|
||||
# safe_load, а не safe_dump: dump сериализует текст обратно
|
||||
# в строку, isinstance(data, dict) никогда не выполнялось, и
|
||||
# таблица цен из pricing.yaml не загружалась ни разу. Тихо:
|
||||
# ошибку глушил except.
|
||||
data = yaml.safe_load(p.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict) and "pricing" in data:
|
||||
self._pricing_table = dict(data["pricing"])
|
||||
return
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import time
|
|||
import dataclasses
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from antigravity_provider import paths
|
||||
from antigravity_provider.version import __version__
|
||||
|
|
@ -105,6 +106,63 @@ def get_auth_token(x_hub_token: str = Header(None)) -> bool:
|
|||
raise HTTPException(status_code=401, detail="Invalid X-Hub-Token")
|
||||
return True
|
||||
|
||||
# Небезопасные методы принимаются только от собственного интерфейса.
|
||||
#
|
||||
# На loopback токен не требуется вовсе (см. get_auth_token), а POST /api/action
|
||||
# меняет состояние: удаляет учётные данные, чистит аккаунты, переключает
|
||||
# маршрутизацию, запускает входы OAuth. CORS от этого не защищает — он мешает
|
||||
# ПРОЧИТАТЬ ответ, а не отправить запрос.
|
||||
#
|
||||
# Проверено на конфигурации по умолчанию (web_api_host=127.0.0.1): POST с
|
||||
# Content-Type: text/plain уходит кросс-сайтом без предварительного запроса
|
||||
# (простой запрос по правилам CORS), а request.json() разбирает тело
|
||||
# независимо от Content-Type. Запрос с Origin: https://evil.example.com и без
|
||||
# токена доходил до исполнителя действий — отвечало уже само действие.
|
||||
#
|
||||
# Проверяется Sec-Fetch-Site, а при его отсутствии — Origin против адреса, по
|
||||
# которому пришёл запрос. Отсутствие обоих заголовков означает не-браузерного
|
||||
# клиента (curl, собственный CLI): браузер на кросс-сайтовый POST заголовок
|
||||
# Origin ставит обязательно, поэтому его отсутствие подделкой не является.
|
||||
_SAME_SITE_FETCH_VALUES = {"same-origin", "none"}
|
||||
|
||||
|
||||
def _configured_allowed_origins() -> set:
|
||||
raw = str(_web_settings().get("web_api_allowed_origins", "")).split(",")
|
||||
return {o.strip() for o in raw if o.strip() and o.strip() != "*"}
|
||||
|
||||
|
||||
def require_same_origin(request: Request) -> bool:
|
||||
"""Отклонить межсайтовый небезопасный запрос до того, как он что-то изменит."""
|
||||
fetch_site = (request.headers.get("sec-fetch-site") or "").strip().lower()
|
||||
if fetch_site:
|
||||
if fetch_site in _SAME_SITE_FETCH_VALUES:
|
||||
return True
|
||||
if request.headers.get("origin", "") in _configured_allowed_origins():
|
||||
return True
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Межсайтовый запрос отклонён (Sec-Fetch-Site: {fetch_site})",
|
||||
)
|
||||
|
||||
origin = request.headers.get("origin")
|
||||
if not origin:
|
||||
return True
|
||||
|
||||
host = (request.headers.get("host") or "").strip().lower()
|
||||
try:
|
||||
origin_host = urlparse(origin).netloc.strip().lower()
|
||||
except Exception:
|
||||
origin_host = ""
|
||||
if origin_host and host and origin_host == host:
|
||||
return True
|
||||
if origin in _configured_allowed_origins():
|
||||
return True
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Origin не совпадает с адресом хаба: межсайтовый запрос отклонён",
|
||||
)
|
||||
|
||||
|
||||
# Коммит и время запуска СНИМАЮТСЯ ОДИН РАЗ, при старте процесса.
|
||||
#
|
||||
# get_installed_commit() читает манифест с диска при каждом вызове, поэтому
|
||||
|
|
@ -283,7 +341,11 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
|
|||
return JSONResponse(content=jsonable_encoder(snap_dict))
|
||||
|
||||
@app.post("/api/action")
|
||||
async def handle_action(request: Request, authorized: bool = Depends(get_auth_token)):
|
||||
async def handle_action(
|
||||
request: Request,
|
||||
authorized: bool = Depends(get_auth_token),
|
||||
same_origin: bool = Depends(require_same_origin),
|
||||
):
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
|
|
@ -356,7 +418,11 @@ def get_skills_endpoint(authorized: bool = Depends(get_auth_token)):
|
|||
|
||||
|
||||
@app.post("/api/skills/assign")
|
||||
async def assign_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
|
||||
async def assign_skill_endpoint(
|
||||
request: Request,
|
||||
authorized: bool = Depends(get_auth_token),
|
||||
same_origin: bool = Depends(require_same_origin),
|
||||
):
|
||||
"""Assign a skill to a specific subagent."""
|
||||
from antigravity_provider.router.skills_service import SkillsService
|
||||
try:
|
||||
|
|
@ -377,7 +443,11 @@ async def assign_skill_endpoint(request: Request, authorized: bool = Depends(get
|
|||
|
||||
|
||||
@app.post("/api/skills/unassign")
|
||||
async def unassign_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
|
||||
async def unassign_skill_endpoint(
|
||||
request: Request,
|
||||
authorized: bool = Depends(get_auth_token),
|
||||
same_origin: bool = Depends(require_same_origin),
|
||||
):
|
||||
"""Remove an assigned skill from a subagent."""
|
||||
from antigravity_provider.router.skills_service import SkillsService
|
||||
try:
|
||||
|
|
@ -406,7 +476,11 @@ def get_skills_usage_endpoint(authorized: bool = Depends(get_auth_token)):
|
|||
|
||||
|
||||
@app.post("/api/skills/diagnose")
|
||||
async def diagnose_skill_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
|
||||
async def diagnose_skill_endpoint(
|
||||
request: Request,
|
||||
authorized: bool = Depends(get_auth_token),
|
||||
same_origin: bool = Depends(require_same_origin),
|
||||
):
|
||||
"""Run SkillDoctor diagnostics on a skill by name, filepath, or raw content."""
|
||||
from antigravity_provider.router.skills_service import SkillsService
|
||||
try:
|
||||
|
|
@ -456,7 +530,11 @@ def get_compression_history_endpoint(limit: int = 20, authorized: bool = Depends
|
|||
|
||||
|
||||
@app.post("/api/compression/test")
|
||||
async def test_compression_endpoint(request: Request, authorized: bool = Depends(get_auth_token)):
|
||||
async def test_compression_endpoint(
|
||||
request: Request,
|
||||
authorized: bool = Depends(get_auth_token),
|
||||
same_origin: bool = Depends(require_same_origin),
|
||||
):
|
||||
"""Execute test context compression on synthetic benchmark prompt."""
|
||||
from antigravity_provider.router.settings_service import get_hub_settings
|
||||
from antigravity_provider.router.local_supervisor import LocalSupervisor
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import logging
|
|||
import os
|
||||
import re
|
||||
import shutil
|
||||
import zipfile
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
|
@ -351,6 +352,43 @@ def acknowledge_last_applied_update() -> None:
|
|||
logger.debug("Failed acknowledging last_applied_update: %s", exc)
|
||||
|
||||
|
||||
def _extract_within(zf: "zipfile.ZipFile", dest: Path) -> None:
|
||||
"""Распаковать архив, не выпуская ни одной записи за пределы dest.
|
||||
|
||||
Отклоняются: абсолютные пути, выход через "..", символические ссылки и
|
||||
любые записи, не являющиеся обычным файлом или каталогом.
|
||||
"""
|
||||
import stat as _stat
|
||||
|
||||
root = dest.resolve()
|
||||
for info in zf.infolist():
|
||||
# Биты типа файла проставлены не всегда: архиватор мог записать только
|
||||
# права доступа. Судим лишь тогда, когда тип действительно указан, —
|
||||
# иначе обычный файл с правами 0o600 выглядел бы записью чужого типа.
|
||||
file_type = _stat.S_IFMT(info.external_attr >> 16)
|
||||
if file_type and file_type not in (_stat.S_IFREG, _stat.S_IFDIR):
|
||||
raise ValueError(
|
||||
f"Пакет обновления содержит запись недопустимого типа: {info.filename!r}"
|
||||
)
|
||||
|
||||
name = info.filename.replace("\\", "/")
|
||||
if name.startswith("/") or re.match(r"^[A-Za-z]:", name):
|
||||
raise ValueError(f"Пакет обновления содержит абсолютный путь: {info.filename!r}")
|
||||
|
||||
target = (root / name).resolve()
|
||||
if target != root and root not in target.parents:
|
||||
raise ValueError(
|
||||
f"Пакет обновления пытается записать за пределы каталога установки: {info.filename!r}"
|
||||
)
|
||||
|
||||
if info.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(info, "r") as source, open(target, "wb") as out:
|
||||
shutil.copyfileobj(source, out)
|
||||
|
||||
|
||||
def stop_running_hub(timeout_sec: float = 10.0) -> bool:
|
||||
"""Останавливает только процессы хаба текущего пользователя, исключая текущий PID."""
|
||||
current_pid = os.getpid()
|
||||
|
|
@ -993,6 +1031,19 @@ class UpdateManager:
|
|||
chosen_url = assets[linux_name]
|
||||
break
|
||||
|
||||
# Платформа не распознана штатной веткой (is_win разошёлся с тем, что
|
||||
# реально есть в релизе) — переберём любое известное имя установщика,
|
||||
# прежде чем сдаваться на .zip. Найдено живым прогоном (A61): без
|
||||
# этой подстраховки единственная опечатка в определении платформы
|
||||
# роняла обновление с «не найден подходящий файл», хотя нужный
|
||||
# установщик в релизе был.
|
||||
if not chosen_url:
|
||||
for known_name in ("hermes-hub-setup.sh", "install-linux.sh", "HermesHubSetup.exe"):
|
||||
if known_name in assets:
|
||||
chosen_asset_name = known_name
|
||||
chosen_url = assets[known_name]
|
||||
break
|
||||
|
||||
# Fallback to any .zip package in assets or manifest package_url
|
||||
if not chosen_url:
|
||||
for a_name, a_url in assets.items():
|
||||
|
|
@ -1212,8 +1263,6 @@ class UpdateManager:
|
|||
|
||||
def apply_update_sync(self, package_zip: Path, target_dir: Optional[Path] = None) -> Tuple[bool, str]:
|
||||
"""Apply update package with automatic backup and rollback on failure."""
|
||||
import zipfile
|
||||
|
||||
dest = target_dir or paths.get_repo_root()
|
||||
backup = self.backup_dir
|
||||
backup.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -1229,8 +1278,15 @@ class UpdateManager:
|
|||
shutil.copytree(src_item, dst_item)
|
||||
|
||||
# 2. Extract update package into dest
|
||||
#
|
||||
# Каждая запись проверяется до записи на диск. Измерено, что
|
||||
# extractall в CPython уже отбрасывает "..", ведущие разделители и
|
||||
# буквы дисков, а запись-ссылку кладёт обычным файлом: побега из
|
||||
# каталога добиться не удалось. Но это свойство реализации, а не
|
||||
# обещание формата — а распаковка идёт в корень установки. Проверка
|
||||
# делает границу собственным инвариантом, который виден в тестах.
|
||||
with zipfile.ZipFile(package_zip, "r") as zf:
|
||||
zf.extractall(dest)
|
||||
_extract_within(zf, dest)
|
||||
|
||||
# 3. Verify syntax and integrity of updated python files in src/
|
||||
import py_compile
|
||||
|
|
|
|||
|
|
@ -396,3 +396,150 @@ def test_destructive_command_with_tilde_is_rejected(monkeypatch, tmp_path):
|
|||
# Обычная работа внутри проекта не должна страдать.
|
||||
allowed, _reason, _alt = guard.validate_command("rm src/temp_file.py")
|
||||
assert allowed, "защита мешает штатной работе внутри проекта"
|
||||
|
||||
|
||||
# ── HUB-1: граница обязана держаться одинаково на Windows и Linux ──
|
||||
#
|
||||
# Инвариант A37 падал только на Windows-раннере: "rm -rf $HOME/.hermes"
|
||||
# проходил мимо защиты, потому что переменной HOME в окружении Windows нет.
|
||||
# Прогон на одной системе этого не показывал. Тесты ниже воспроизводят
|
||||
# окружение обеих систем на любой из них, поэтому дыра больше не может
|
||||
# спрятаться за тем, где именно запущен CI.
|
||||
|
||||
_DESTRUCTIVE_BOTH_DIALECTS = [
|
||||
"rm -rf ~/.hermes/agy_profiles",
|
||||
"rm -rf ~/.ssh",
|
||||
"rm -rf $HOME/.hermes",
|
||||
"rm -rf ${HOME}/.hermes",
|
||||
r"rm -rf %USERPROFILE%\.hermes",
|
||||
r"del /f /q %USERPROFILE%\.ssh",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guard_env(monkeypatch, tmp_path):
|
||||
"""Guard с HERMES_HOME во временном каталоге — вне домашнего и вне проекта."""
|
||||
from antigravity_provider.router.security_guard import WorkspaceBoundaryGuard
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
return WorkspaceBoundaryGuard()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("simulated_os", ["linux", "windows"])
|
||||
@pytest.mark.parametrize("cmd", _DESTRUCTIVE_BOTH_DIALECTS)
|
||||
def test_home_directed_destruction_rejected_on_both_systems(monkeypatch, guard_env, simulated_os, cmd):
|
||||
"""Удаление по домашнему каталогу отклоняется в обоих окружениях.
|
||||
|
||||
Окружение Windows отличается от Linux ровно тем, из-за чего защита и
|
||||
расходилась: HOME не задан, домашний каталог известен через USERPROFILE.
|
||||
"""
|
||||
if simulated_os == "windows":
|
||||
monkeypatch.delenv("HOME", raising=False)
|
||||
monkeypatch.setenv("USERPROFILE", str(Path.home()))
|
||||
else:
|
||||
monkeypatch.setenv("HOME", str(Path.home()))
|
||||
monkeypatch.delenv("USERPROFILE", raising=False)
|
||||
|
||||
allowed, reason, _alt = guard_env.validate_command(cmd)
|
||||
assert not allowed, f"[{simulated_os}] команда прошла мимо защиты: {cmd} ({reason})"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("simulated_os", ["linux", "windows"])
|
||||
def test_guard_fails_closed_on_unresolvable_argument(monkeypatch, guard_env, simulated_os):
|
||||
"""Нераскрытая переменная — отказ, а не пропуск.
|
||||
|
||||
Раньше "$HOME/.hermes" с неизвестной переменной переставал быть абсолютным
|
||||
путём, склеивался с каталогом проекта и признавался допустимым. Путь,
|
||||
который нельзя разрешить, нельзя и признать безопасным.
|
||||
"""
|
||||
if simulated_os == "windows":
|
||||
monkeypatch.delenv("HOME", raising=False)
|
||||
monkeypatch.delenv("HERMES_UNSET_TARGET", raising=False)
|
||||
|
||||
for cmd in ["rm -rf $HERMES_UNSET_TARGET/data", r"rm -rf %HERMES_UNSET_TARGET%\data"]:
|
||||
allowed, reason, _alt = guard_env.validate_command(cmd)
|
||||
assert not allowed, f"[{simulated_os}] guard не закрылся на нераскрытом пути: {cmd} ({reason})"
|
||||
|
||||
|
||||
def test_foreign_dialect_absolute_path_is_not_treated_as_project_local(guard_env):
|
||||
"""Путь с буквой диска на Linux не должен считаться внутренним.
|
||||
|
||||
Path("C:/Windows").resolve() на Linux приписывает пути текущий каталог, и
|
||||
удаление системного каталога Windows выглядело как работа внутри проекта.
|
||||
"""
|
||||
allowed, reason, _alt = guard_env.validate_command(r"del /f /q C:\Windows\System32")
|
||||
assert not allowed, f"путь чужого диалекта признан внутренним: {reason}"
|
||||
|
||||
|
||||
def test_normal_work_inside_project_still_allowed(guard_env):
|
||||
"""Ужесточение не должно мешать штатной работе."""
|
||||
for cmd in ["rm src/temp_file.py", "rm -rf build/", "rm ./tests/tmp.log"]:
|
||||
allowed, reason, _alt = guard_env.validate_command(cmd)
|
||||
assert allowed, f"защита мешает штатной работе: {cmd} ({reason})"
|
||||
|
||||
|
||||
# ── HUB-1: небезопасные методы не принимаются с чужой страницы ──
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loopback_client():
|
||||
"""Клиент при конфигурации по умолчанию: web_api_host=127.0.0.1, токен не нужен."""
|
||||
import antigravity_provider.router.web.server as srv
|
||||
|
||||
with patch.object(srv, "_web_settings", return_value={"web_api_host": "127.0.0.1"}):
|
||||
yield TestClient(app)
|
||||
|
||||
|
||||
_UNSAFE_ENDPOINTS = [
|
||||
("/api/action", '{"action": "clear_accounts", "data": {}}'),
|
||||
("/api/skills/assign", '{"skill": "x", "profile": "y"}'),
|
||||
("/api/skills/unassign", '{"skill": "x", "profile": "y"}'),
|
||||
("/api/skills/diagnose", '{"skill": "x"}'),
|
||||
("/api/compression/test", '{"text": "x"}'),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path, body", _UNSAFE_ENDPOINTS)
|
||||
def test_cross_site_post_is_rejected(loopback_client, path, body):
|
||||
"""Межсайтовый POST отклоняется до того, как что-либо изменит.
|
||||
|
||||
На loopback токен не требуется, а действия меняют состояние: удаляют
|
||||
учётные данные, чистят аккаунты, переключают маршрутизацию. CORS от этого
|
||||
не защищает — он мешает прочитать ответ, а не отправить запрос. Измерено:
|
||||
POST с Content-Type text/plain уходит кросс-сайтом без предварительного
|
||||
запроса, request.json() разбирает тело независимо от Content-Type, и
|
||||
запрос с чужим Origin доходил до исполнителя действий.
|
||||
"""
|
||||
headers = {
|
||||
"Content-Type": "text/plain;charset=UTF-8",
|
||||
"Origin": "https://evil.example.com",
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
}
|
||||
res = loopback_client.post(path, content=body, headers=headers)
|
||||
assert res.status_code == 403, f"{path} принял межсайтовый запрос: HTTP {res.status_code}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path, body", _UNSAFE_ENDPOINTS)
|
||||
def test_cross_site_post_rejected_without_fetch_metadata(loopback_client, path, body):
|
||||
"""Браузер без Sec-Fetch-* всё равно ставит Origin — по нему и отклоняем."""
|
||||
headers = {"Content-Type": "text/plain", "Origin": "https://evil.example.com"}
|
||||
res = loopback_client.post(path, content=body, headers=headers)
|
||||
assert res.status_code == 403, f"{path} принял запрос с чужим Origin: HTTP {res.status_code}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"label, headers",
|
||||
[
|
||||
("собственный интерфейс", {"Origin": "http://testserver", "Sec-Fetch-Site": "same-origin"}),
|
||||
("адресная строка", {"Sec-Fetch-Site": "none"}),
|
||||
("не-браузерный клиент", {}),
|
||||
],
|
||||
)
|
||||
def test_own_interface_and_cli_still_work(loopback_client, label, headers):
|
||||
"""Защита не должна мешать собственному интерфейсу и не-браузерным клиентам."""
|
||||
res = loopback_client.post(
|
||||
"/api/action",
|
||||
json={"action": "___нет_такого___", "data": {}},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code != 403, f"{label} отклонён межсайтовой защитой"
|
||||
|
|
|
|||
|
|
@ -249,7 +249,14 @@ def test_p0_5_verification_script_runs_clean_and_filled(clean_a41_env):
|
|||
env["HERMES_HOME"] = str(hermes_home)
|
||||
|
||||
script_path = Path(__file__).resolve().parent.parent / "scripts" / "verify_multi_provider_router.py"
|
||||
proc = subprocess.run([sys.executable, str(script_path)], env=env, capture_output=True, text=True)
|
||||
|
||||
# Кодировка задаётся явно с обеих сторон трубы. Скрипт печатает по-русски и
|
||||
# сам переводит вывод на UTF-8; родитель же на Windows по умолчанию читает
|
||||
# трубу в кодировке системы (cp1252) и разваливается на UnicodeDecodeError,
|
||||
# оставляя proc.stdout равным None — падал разбор вывода, а не проверки.
|
||||
run_kwargs = dict(env=env, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
|
||||
proc = subprocess.run([sys.executable, str(script_path)], **run_kwargs)
|
||||
assert proc.returncode == 0, f"Verification failed on clean config: {proc.stderr}\n{proc.stdout}"
|
||||
assert "10/10 CHECKS PASSED" in proc.stdout
|
||||
|
||||
|
|
@ -259,6 +266,6 @@ def test_p0_5_verification_script_runs_clean_and_filled(clean_a41_env):
|
|||
AutoAssigner.assign_profile_to_role("codex-1", "manager", is_primary=True)
|
||||
AutoAssigner.assign_profile_to_role("ag-1", "manager", is_primary=False)
|
||||
|
||||
proc_filled = subprocess.run([sys.executable, str(script_path)], env=env, capture_output=True, text=True)
|
||||
proc_filled = subprocess.run([sys.executable, str(script_path)], **run_kwargs)
|
||||
assert proc_filled.returncode == 0, f"Verification failed on filled config: {proc_filled.stderr}\n{proc_filled.stdout}"
|
||||
assert "10/10 CHECKS PASSED" in proc_filled.stdout
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ Verifies:
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
|
@ -249,23 +251,44 @@ def test_p0_2_sha256_mismatch_aborts_and_sets_failed_status(tmp_path, monkeypatc
|
|||
|
||||
|
||||
# ── TEST 5: P0-3 Process Isolation stop_running_hub ──
|
||||
@pytest.mark.parametrize("simulated_os", ["linux", "windows"])
|
||||
@pytest.mark.unit
|
||||
def test_p0_3_stop_running_hub_isolates_user_and_excludes_current_pid():
|
||||
"""stop_running_hub filters by current UID on Linux and never targets own PID."""
|
||||
current_pid = os.getpid()
|
||||
def test_p0_3_stop_running_hub_isolates_user_and_excludes_current_pid(simulated_os):
|
||||
"""Чужие процессы хаба останавливаются, собственный — никогда.
|
||||
|
||||
# Mock subprocess.run for pgrep
|
||||
with patch("subprocess.run") as mock_run:
|
||||
# Simulate pgrep returning other PID and own PID
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"99999 {current_pid}\n")
|
||||
Проверяется на обеих ветках, а не на той, где случился прогон. Ветки
|
||||
останавливают процессы по-разному: на Linux — os.kill по списку от pgrep,
|
||||
на Windows — taskkill по списку от wmic. Тест знал только про первую и на
|
||||
Windows-раннере падал на пустом списке убитых, хотя проверять надо один и
|
||||
тот же инвариант — «свой PID не трогаем».
|
||||
"""
|
||||
current_pid = os.getpid()
|
||||
is_win = simulated_os == "windows"
|
||||
|
||||
with patch("antigravity_provider.updater.update_manager.sys") as mock_sys:
|
||||
mock_sys.platform = "win32" if is_win else "linux"
|
||||
|
||||
# На Windows os.getuid не существует; ветка Linux падала бы на нём в
|
||||
# общий except и возвращала «остановлено» никого не остановив. create=True
|
||||
# позволяет подставить его там, где его нет.
|
||||
with patch("os.getuid", return_value=1000, create=True), patch("subprocess.run") as mock_run:
|
||||
# wmic и pgrep перечисляют один и тот же набор: чужой PID и свой.
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=f"99999\n{current_pid}\n")
|
||||
|
||||
with patch("os.kill") as mock_kill:
|
||||
stop_running_hub(timeout_sec=0.1)
|
||||
|
||||
# Check that kill was called on 99999 but NEVER on current_pid
|
||||
if is_win:
|
||||
killed_pids = [
|
||||
int(call.args[0][-1])
|
||||
for call in mock_run.call_args_list
|
||||
if call.args and call.args[0] and call.args[0][0] == "taskkill"
|
||||
]
|
||||
else:
|
||||
killed_pids = [call.args[0] for call in mock_kill.call_args_list]
|
||||
assert 99999 in killed_pids
|
||||
assert current_pid not in killed_pids, "stop_running_hub must never kill current PID"
|
||||
|
||||
assert 99999 in killed_pids, f"[{simulated_os}] чужой процесс хаба не остановлен: {killed_pids}"
|
||||
assert current_pid not in killed_pids, f"[{simulated_os}] остановлен собственный процесс"
|
||||
|
||||
|
||||
# ── TEST 6: P0-3 apply_update_sync Rollback on Corruption ──
|
||||
|
|
@ -381,8 +404,7 @@ def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monke
|
|||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
|
||||
|
||||
installer = tmp_path / "hermes-hub-setup.sh"
|
||||
installer.write_bytes(b"#!/bin/bash\nexit 3\n")
|
||||
with platform_installer(tmp_path, exit_code=3) as (asset_name, installer):
|
||||
sha = hashlib.sha256(installer.read_bytes()).hexdigest()
|
||||
|
||||
check_result = UpdateCheckResult(
|
||||
|
|
@ -392,7 +414,7 @@ def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monke
|
|||
latest_commit="deadbeefdeadbeef",
|
||||
installed_commit="0000000aaaa",
|
||||
assets={
|
||||
"hermes-hub-setup.sh": f"file://{installer}",
|
||||
asset_name: f"file://{installer}",
|
||||
"checksums.txt": "file:///nonexistent",
|
||||
},
|
||||
)
|
||||
|
|
@ -402,7 +424,7 @@ def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monke
|
|||
|
||||
def fake_download(self, url, dest, progress_cb=None):
|
||||
if dest.name == "checksums.txt":
|
||||
dest.write_text(f"{sha} hermes-hub-setup.sh\n", encoding="utf-8")
|
||||
dest.write_text(f"{sha} {asset_name}\n", encoding="utf-8")
|
||||
return
|
||||
return real_download(self, url, dest, progress_cb)
|
||||
|
||||
|
|
@ -414,7 +436,11 @@ def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monke
|
|||
assert get_last_applied_update() is None, (
|
||||
"После провалившейся установки записи о применённом обновлении быть не должно"
|
||||
)
|
||||
assert "код 3" in msg, f"Причина отказа должна называть код возврата, получено: {msg!r}"
|
||||
# Проверяется, что код назван, а не как он склоняется: ветки формулируют
|
||||
# по-разному («код 3» и «кодом 3»), инвариант же один.
|
||||
assert re.search(r"код\w*\s+3", msg), (
|
||||
f"Причина отказа должна называть код возврата, получено: {msg!r}"
|
||||
)
|
||||
|
||||
prog = UpdateManager.get_progress_dict()
|
||||
assert prog["status"] == "failed"
|
||||
|
|
@ -422,6 +448,31 @@ def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monke
|
|||
UpdateManager._set_progress(status="idle", message="Готов к обновлению")
|
||||
|
||||
|
||||
# ── Установщик под ту систему, на которой идёт прогон ──
|
||||
#
|
||||
# Ветки установки различаются: на Windows выбирается HermesHubSetup.exe и
|
||||
# запускается через Popen, на Linux — hermes-hub-setup.sh через bash. Тесты
|
||||
# ниже проверяют не установщик, а учёт его результата, поэтому подставляется
|
||||
# тот файл, который данная система действительно выбирает. Раньше в них был
|
||||
# зашит bash-скрипт, и на Windows-раннере установка отвечала «в релизе не
|
||||
# найден подходящий файл обновления» — падало допущение теста, не продукт.
|
||||
|
||||
@contextlib.contextmanager
|
||||
def platform_installer(tmp_path, exit_code: int):
|
||||
"""Отдать (имя ассета, путь) и заставить установщик вернуть exit_code."""
|
||||
if sys.platform == "win32":
|
||||
installer = tmp_path / "HermesHubSetup.exe"
|
||||
# Содержимое не исполняется: запуск подменён, проверяется учёт кода.
|
||||
installer.write_bytes(b"MZ\x90\x00 hermes hub test installer\n")
|
||||
with patch("subprocess.Popen") as mock_popen:
|
||||
mock_popen.return_value = MagicMock(wait=MagicMock(return_value=exit_code))
|
||||
yield "HermesHubSetup.exe", installer
|
||||
else:
|
||||
installer = tmp_path / "hermes-hub-setup.sh"
|
||||
installer.write_bytes(f"#!/bin/bash\nexit {exit_code}\n".encode("utf-8"))
|
||||
yield "hermes-hub-setup.sh", installer
|
||||
|
||||
|
||||
# ── TEST 10: P0-4 успешная установка запись всё-таки делает ──
|
||||
@pytest.mark.unit
|
||||
def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkeypatch):
|
||||
|
|
@ -431,8 +482,7 @@ def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkey
|
|||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
|
||||
|
||||
installer = tmp_path / "hermes-hub-setup.sh"
|
||||
installer.write_bytes(b"#!/bin/bash\nexit 0\n")
|
||||
with platform_installer(tmp_path, exit_code=0) as (asset_name, installer):
|
||||
sha = hashlib.sha256(installer.read_bytes()).hexdigest()
|
||||
|
||||
check_result = UpdateCheckResult(
|
||||
|
|
@ -442,7 +492,7 @@ def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkey
|
|||
latest_commit="deadbeefdeadbeef",
|
||||
installed_commit="0000000aaaa",
|
||||
assets={
|
||||
"hermes-hub-setup.sh": f"file://{installer}",
|
||||
asset_name: f"file://{installer}",
|
||||
"checksums.txt": "file:///nonexistent",
|
||||
},
|
||||
)
|
||||
|
|
@ -452,7 +502,7 @@ def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkey
|
|||
|
||||
def fake_download(self, url, dest, progress_cb=None):
|
||||
if dest.name == "checksums.txt":
|
||||
dest.write_text(f"{sha} hermes-hub-setup.sh\n", encoding="utf-8")
|
||||
dest.write_text(f"{sha} {asset_name}\n", encoding="utf-8")
|
||||
return
|
||||
return real_download(self, url, dest, progress_cb)
|
||||
|
||||
|
|
@ -506,3 +556,59 @@ def test_p0_2_app_js_handles_refused_cancel():
|
|||
assert "setInterval(pollUpdateProgress" in tail, (
|
||||
"после отклонённой отмены опрос хода загрузки должен возобновляться"
|
||||
)
|
||||
|
||||
|
||||
# ── HUB-1: распаковка обновления не выпускает записи за пределы каталога ──
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_update_package_cannot_write_outside_target(tmp_path):
|
||||
"""Ни одна запись архива не должна оказаться вне каталога установки.
|
||||
|
||||
Измерено на CPython: extractall сам отбрасывает "..", ведущие разделители
|
||||
и буквы дисков, а запись-ссылку кладёт обычным файлом — побега добиться не
|
||||
удалось, вопреки формулировке аудита. Но это свойство реализации, а не
|
||||
обещание формата, и распаковка идёт в корень установки. Тест закрепляет
|
||||
границу как собственный инвариант.
|
||||
"""
|
||||
import stat as _stat
|
||||
import zipfile as _zipfile
|
||||
from antigravity_provider.updater.update_manager import _extract_within
|
||||
|
||||
dest = tmp_path / "dest"
|
||||
dest.mkdir()
|
||||
outside = tmp_path / "outside.txt"
|
||||
|
||||
hostile = [
|
||||
("выход через ..", "../outside.txt"),
|
||||
("абсолютный путь", "/etc/passwd"),
|
||||
("путь с буквой диска", "C:/Windows/x.txt"),
|
||||
]
|
||||
for index, (label, arcname) in enumerate(hostile):
|
||||
archive = tmp_path / f"hostile_{index}.zip"
|
||||
with _zipfile.ZipFile(archive, "w") as zf:
|
||||
zf.writestr(arcname, "побег")
|
||||
with _zipfile.ZipFile(archive) as zf:
|
||||
with pytest.raises(ValueError):
|
||||
_extract_within(zf, dest)
|
||||
assert not outside.exists(), f"{label}: запись оказалась вне каталога установки"
|
||||
|
||||
# Символическая ссылка — тоже отказ, а не молчаливая распаковка файлом.
|
||||
link_zip = tmp_path / "link.zip"
|
||||
with _zipfile.ZipFile(link_zip, "w") as zf:
|
||||
info = _zipfile.ZipInfo("link")
|
||||
info.external_attr = (_stat.S_IFLNK | 0o777) << 16
|
||||
zf.writestr(info, "/etc/passwd")
|
||||
with _zipfile.ZipFile(link_zip) as zf:
|
||||
with pytest.raises(ValueError):
|
||||
_extract_within(zf, dest)
|
||||
|
||||
# Обычный пакет распаковывается как прежде.
|
||||
good = tmp_path / "good.zip"
|
||||
with _zipfile.ZipFile(good, "w") as zf:
|
||||
zf.writestr("src/module.py", "x = 1\n")
|
||||
zf.writestr("assets/logo.txt", "logo")
|
||||
with _zipfile.ZipFile(good) as zf:
|
||||
_extract_within(zf, dest)
|
||||
assert (dest / "src" / "module.py").read_text(encoding="utf-8") == "x = 1\n"
|
||||
assert (dest / "assets" / "logo.txt").is_file()
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
|
@ -39,9 +40,26 @@ def test_silent_installer_execution_with_hermes(tmp_path):
|
|||
if not SETUP_EXE.is_file():
|
||||
pytest.skip("HermesHubSetup.exe not built yet")
|
||||
|
||||
# Set up mock Hermes Agent structure in temp home
|
||||
# Set up mock Hermes Agent structure in temp home pointing to the active venv.
|
||||
#
|
||||
# Пустые touch-файлы python.exe/hermes.exe раньше делали живую проверку
|
||||
# зависимостей на установленной машине бессмысленной: файл существовал,
|
||||
# но не был исполняемым питоном, и по-настоящему установщик его не
|
||||
# запускал ни разу. Найдено прогоном на настоящей машине (A61): junction
|
||||
# (или symlink) на реальный venv делает эту проверку осмысленной —
|
||||
# запускается настоящий интерпретатор.
|
||||
agent_dir = tmp_path / "hermes" / "hermes-agent"
|
||||
venv_scripts = agent_dir / "venv" / "Scripts"
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
real_venv = Path(sys.prefix)
|
||||
target_venv = agent_dir / "venv"
|
||||
try:
|
||||
import _winapi
|
||||
_winapi.CreateJunction(str(real_venv), str(target_venv))
|
||||
except Exception:
|
||||
try:
|
||||
os.symlink(str(real_venv), str(target_venv), target_is_directory=True)
|
||||
except Exception:
|
||||
venv_scripts = target_venv / "Scripts"
|
||||
venv_scripts.mkdir(parents=True, exist_ok=True)
|
||||
(venv_scripts / "python.exe").touch()
|
||||
(venv_scripts / "hermes.exe").touch()
|
||||
|
|
@ -54,7 +72,7 @@ def test_silent_installer_execution_with_hermes(tmp_path):
|
|||
env["HERMES_HUB_NO_REGISTRY"] = "1"
|
||||
|
||||
res = subprocess.run([str(SETUP_EXE), "/silent"], env=env, capture_output=True, text=True)
|
||||
assert res.returncode == 0, f"Expected returncode 0, got {res.returncode}. Stderr: {res.stderr}"
|
||||
assert res.returncode == 0, f"Expected returncode 0, got {res.returncode}. Stderr: {res.stderr}. Stdout: {res.stdout}"
|
||||
|
||||
|
||||
@pytest.mark.installer
|
||||
|
|
|
|||
|
|
@ -87,6 +87,17 @@ def test_linux_installer_script_structure():
|
|||
assert "HERMES_HOME" in install_sh
|
||||
assert "antigravity-provider" in install_sh
|
||||
|
||||
# Значок .desktop-записи должен быть растровым (PNG/SVG), не .ico.
|
||||
#
|
||||
# Измерено на настоящем GTK-рабочем столе: GdkPixbuf.Pixbuf.new_from_file
|
||||
# на .ico падает с "Compressed icons are not supported", а .desktop-файл
|
||||
# с нерендерящейся иконкой меню приложений и файловый менеджер просто
|
||||
# показывают пустым — без ошибки, молча. HermesHub.ico в тех же ассетах
|
||||
# существует и раньше подставлялся сюда, поэтому мало проверить, что путь
|
||||
# не пуст — нужно, чтобы это не был именно .ico.
|
||||
icon_line = next(line for line in install_sh.splitlines() if line.startswith("ICON_PATH="))
|
||||
assert ".ico" not in icon_line, f"иконка .desktop-записи — .ico, GTK его не рендерит: {icon_line!r}"
|
||||
|
||||
|
||||
def test_linux_launcher_script_headless_and_app_mode():
|
||||
"""Verify hermes-hub-web.sh checks DISPLAY, prints SSH port forwarding on headless, and uses --app on desktop."""
|
||||
|
|
@ -111,3 +122,49 @@ def test_linux_uninstaller_preserves_user_data():
|
|||
assert "--purge-user-data" in uninstall_sh
|
||||
assert "Preserving user data and credentials" in uninstall_sh
|
||||
assert "hermes-hub-web.desktop" in uninstall_sh
|
||||
|
||||
|
||||
def test_linux_uninstaller_stops_running_hub():
|
||||
"""A61/A62: uninstall-linux.sh must stop the hub before deleting its files.
|
||||
|
||||
Раньше удаление не трогало работающий процесс: rm -rf уходил под живым
|
||||
сервером, а с --purge-user-data ещё и сносил каталоги, на которые у
|
||||
процесса были открыты файловые дескрипторы. Хаб продолжал отвечать по
|
||||
старому порту после «успешного» удаления. Измерено живым прогоном:
|
||||
сервер, запущенный в песочнице, оставался в списке процессов после
|
||||
uninstall-linux.sh до этой правки.
|
||||
"""
|
||||
uninstall_sh = (INSTALLER_DIR / "uninstall-linux.sh").read_text(encoding="utf-8")
|
||||
assert "lib_stop_running_hub.sh" in uninstall_sh
|
||||
assert "stop_running_hub" in uninstall_sh
|
||||
|
||||
|
||||
def test_linux_stop_launcher_exists_and_reuses_shared_logic():
|
||||
"""A61/A62: должен существовать способ остановить хаб не из терминала руками.
|
||||
|
||||
На Windows это «Exit» из системного трея HermesHubWeb.exe. На Linux до
|
||||
этого не было ничего — ни кнопки в интерфейсе (её нет ни на одной
|
||||
платформе), ни трея, ни пункта меню: сервер, оставленный в фоне после
|
||||
закрытия окна браузера, можно было остановить только pkill'ом из
|
||||
терминала. launcher/hermes-hub-stop.sh — недостающий эквивалент,
|
||||
устанавливается install-linux.sh как второй пункт меню приложений.
|
||||
"""
|
||||
stop_sh = (LAUNCHER_DIR / "hermes-hub-stop.sh").read_text(encoding="utf-8")
|
||||
assert "#!/usr/bin/env bash" in stop_sh
|
||||
assert "stop_running_hub" in stop_sh
|
||||
|
||||
lib_sh = (INSTALLER_DIR / "lib_stop_running_hub.sh").read_text(encoding="utf-8")
|
||||
assert "stop_running_hub()" in lib_sh
|
||||
# Общий источник, а не третья копия той же функции: install и uninstall
|
||||
# обязаны ссылаться на тот же файл, а не хранить свою версию.
|
||||
install_sh = (INSTALLER_DIR / "install-linux.sh").read_text(encoding="utf-8")
|
||||
uninstall_sh = (INSTALLER_DIR / "uninstall-linux.sh").read_text(encoding="utf-8")
|
||||
for script_name, script_text in (("install-linux.sh", install_sh), ("uninstall-linux.sh", uninstall_sh)):
|
||||
assert "lib_stop_running_hub.sh" in script_text, f"{script_name} не источает общую функцию"
|
||||
assert script_text.count("stop_running_hub() {") == 0, (
|
||||
f"{script_name} держит собственную копию функции вместо общего источника"
|
||||
)
|
||||
|
||||
assert "hermes-hub-stop" in install_sh, "install-linux.sh не разворачивает лаунчер остановки"
|
||||
assert "hermes-hub-stop.desktop" in install_sh, "у лаунчера остановки нет пункта меню"
|
||||
assert "hermes-hub-stop" in uninstall_sh, "uninstall-linux.sh не убирает лаунчер остановки"
|
||||
|
|
|
|||
|
|
@ -461,3 +461,251 @@ def test_s4_secret_scanner_ast_detection(tmp_path):
|
|||
clean_file.write_text('def hello(): return "world"\n', encoding="utf-8")
|
||||
v3 = scan_file_for_secrets(clean_file)
|
||||
assert len(v3) == 0
|
||||
|
||||
|
||||
# ── HUB-1: ворота публикации не пропускают релиз при любом исходе ──
|
||||
|
||||
|
||||
def _load_release_gate():
|
||||
import importlib
|
||||
import sys
|
||||
scripts_dir = str(Path(__file__).resolve().parent.parent / "scripts")
|
||||
if scripts_dir not in sys.path:
|
||||
sys.path.insert(0, scripts_dir)
|
||||
import release_gate
|
||||
importlib.reload(release_gate)
|
||||
return release_gate
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize(
|
||||
"failure, expected_in_message",
|
||||
[
|
||||
("network", "недоступен"),
|
||||
("http_404", "404"),
|
||||
("no_assets", "ассет"),
|
||||
("no_checksums", "checksums.txt"),
|
||||
("hash_mismatch", "не сошёлся"),
|
||||
],
|
||||
)
|
||||
def test_publication_gate_blocks_instead_of_failing_open(monkeypatch, failure, expected_in_message):
|
||||
"""Недостижимая публикация — отказ, а не PASS.
|
||||
|
||||
Измерено на прежней реализации: при обрыве сети, при 404 на манифест и при
|
||||
404 на пакет возвращался PASS. Ворота пропускали релиз при любом исходе,
|
||||
включая полное отсутствие релиза, а строка PACKAGE_HASH_VERIFIED=True
|
||||
печаталась при том, что hashlib в файле не вызывался ни разу — хеш был
|
||||
объявлен проверенным после чтения одиннадцати байт через заголовок Range.
|
||||
"""
|
||||
import urllib.error
|
||||
release_gate = _load_release_gate()
|
||||
monkeypatch.setenv(release_gate.PUBLICATION_MODE_ENV, "1")
|
||||
monkeypatch.setattr(release_gate.sys, "argv", ["release_gate.py"])
|
||||
|
||||
manifest = {
|
||||
"tag_name": "v9.9.9",
|
||||
"assets": [
|
||||
{"name": "HermesHubSetup.exe", "browser_download_url": "https://example.invalid/setup.exe"},
|
||||
{"name": "checksums.txt", "browser_download_url": "https://example.invalid/checksums.txt"},
|
||||
],
|
||||
}
|
||||
if failure == "no_assets":
|
||||
manifest["assets"] = []
|
||||
if failure == "no_checksums":
|
||||
manifest["assets"] = [manifest["assets"][0]]
|
||||
|
||||
class _Resp:
|
||||
status = 200
|
||||
|
||||
def __init__(self, payload: bytes):
|
||||
self._payload = payload
|
||||
|
||||
def read(self, *_a):
|
||||
payload, self._payload = self._payload, b""
|
||||
return payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_a):
|
||||
return False
|
||||
|
||||
def fake_get(url, timeout=30):
|
||||
if failure == "network":
|
||||
raise urllib.error.URLError("сети нет")
|
||||
if failure == "http_404":
|
||||
raise urllib.error.HTTPError(url, 404, "Not Found", {}, None)
|
||||
if url.endswith("checksums.txt"):
|
||||
return _Resp(b"%s HermesHubSetup.exe\n" % (b"a" * 64))
|
||||
if url.endswith("setup.exe"):
|
||||
return _Resp(b"payload-with-a-different-hash")
|
||||
return _Resp(json.dumps(manifest).encode("utf-8"))
|
||||
|
||||
monkeypatch.setattr(release_gate, "_http_get", fake_get)
|
||||
|
||||
ok, msg = release_gate.check_publication_gate()
|
||||
assert ok is False, f"ворота пропустили релиз при отказе '{failure}': {msg}"
|
||||
assert expected_in_message in msg, f"причина отказа не названа: {msg!r}"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_publication_gate_hashes_the_whole_package(monkeypatch):
|
||||
"""Успех объявляется только после полного скачивания и сверки SHA-256."""
|
||||
import hashlib
|
||||
release_gate = _load_release_gate()
|
||||
monkeypatch.setenv(release_gate.PUBLICATION_MODE_ENV, "1")
|
||||
monkeypatch.setattr(release_gate.sys, "argv", ["release_gate.py"])
|
||||
|
||||
package = b"hermes hub installer payload"
|
||||
real_sha = hashlib.sha256(package).hexdigest()
|
||||
read_bytes = {"total": 0}
|
||||
|
||||
class _Resp:
|
||||
status = 200
|
||||
|
||||
def __init__(self, payload: bytes, count: bool = False):
|
||||
self._payload = payload
|
||||
self._count = count
|
||||
|
||||
def read(self, *_a):
|
||||
payload, self._payload = self._payload, b""
|
||||
if self._count:
|
||||
read_bytes["total"] += len(payload)
|
||||
return payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_a):
|
||||
return False
|
||||
|
||||
manifest = {
|
||||
"tag_name": "v9.9.9",
|
||||
"assets": [
|
||||
{"name": "HermesHubSetup.exe", "browser_download_url": "https://example.invalid/setup.exe"},
|
||||
{"name": "checksums.txt", "browser_download_url": "https://example.invalid/checksums.txt"},
|
||||
],
|
||||
}
|
||||
|
||||
def fake_get(url, timeout=30):
|
||||
if url.endswith("checksums.txt"):
|
||||
return _Resp(f"{real_sha} HermesHubSetup.exe\n".encode("utf-8"))
|
||||
if url.endswith("setup.exe"):
|
||||
return _Resp(package, count=True)
|
||||
return _Resp(json.dumps(manifest).encode("utf-8"))
|
||||
|
||||
monkeypatch.setattr(release_gate, "_http_get", fake_get)
|
||||
|
||||
ok, msg = release_gate.check_publication_gate()
|
||||
assert ok is True, msg
|
||||
assert "PACKAGE_HASH_VERIFIED=True" in msg
|
||||
assert read_bytes["total"] == len(package), (
|
||||
f"пакет должен быть прочитан целиком, прочитано {read_bytes['total']} из {len(package)}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_offline_run_does_not_claim_publication_verified(monkeypatch):
|
||||
"""Без режима публикации отсутствие релиза не блокирует, но и не врёт."""
|
||||
import urllib.error
|
||||
release_gate = _load_release_gate()
|
||||
monkeypatch.delenv(release_gate.PUBLICATION_MODE_ENV, raising=False)
|
||||
monkeypatch.setattr(release_gate.sys, "argv", ["release_gate.py"])
|
||||
|
||||
def fake_get(url, timeout=30):
|
||||
raise urllib.error.HTTPError(url, 404, "Not Found", {}, None)
|
||||
|
||||
monkeypatch.setattr(release_gate, "_http_get", fake_get)
|
||||
|
||||
ok, msg = release_gate.check_publication_gate()
|
||||
assert ok is True, "обычный прогон CI не должен блокироваться отсутствием релиза"
|
||||
assert "НЕ БЛОКИРУЕТ" in msg
|
||||
assert "PACKAGE_HASH_VERIFIED=True" not in msg, "непроверенное не должно объявляться проверенным"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_publishable_assets_check_rejects_uninstallable_release(tmp_path):
|
||||
"""Набор без установщика не должен уходить в публикацию.
|
||||
|
||||
update_manager ищет в релизе HermesHubSetup.exe или hermes-hub-setup.sh, а
|
||||
release.yml собирает только zip и манифест. Такой релиз становится
|
||||
«latest», и обновление отвечает «в релизе не найден подходящий файл
|
||||
обновления для текущей платформы». Раньше это не проявлялось лишь потому,
|
||||
что весь релизный конвейер падал на шаге Release Gate — на тех же двух
|
||||
дефектах, что и CI, — и до публикации не доходил ни один его прогон.
|
||||
"""
|
||||
release_gate = _load_release_gate()
|
||||
|
||||
as_built_today = tmp_path / "dist_zip_only"
|
||||
as_built_today.mkdir()
|
||||
(as_built_today / "hermes-hub-0.1.3.zip").write_bytes(b"zip")
|
||||
(as_built_today / "update_manifest.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
ok, msg = release_gate.check_publishable_assets(as_built_today)
|
||||
assert ok is False, "набор без установщика признан пригодным к публикации"
|
||||
assert "HermesHubSetup.exe" in msg
|
||||
assert "checksums.txt" in msg
|
||||
|
||||
without_checksums = tmp_path / "dist_no_sums"
|
||||
without_checksums.mkdir()
|
||||
(without_checksums / "HermesHubSetup.exe").write_bytes(b"exe")
|
||||
ok, msg = release_gate.check_publishable_assets(without_checksums)
|
||||
assert ok is False, "набор без checksums.txt признан пригодным"
|
||||
assert "checksums.txt" in msg
|
||||
|
||||
ok, msg = release_gate.check_publishable_assets(tmp_path / "нет-такого")
|
||||
assert ok is False, "отсутствующий каталог сборки должен быть отказом"
|
||||
|
||||
|
||||
def _write_installer_with_checksum(directory, name: str, content: bytes) -> None:
|
||||
"""Собрать <name> + checksums.txt с настоящим SHA-256 для теста."""
|
||||
import hashlib
|
||||
|
||||
(directory / name).write_bytes(content)
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
(directory / "checksums.txt").write_text(f"{digest} {name}\n", encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_publishable_assets_check_verifies_size_and_hash(tmp_path):
|
||||
"""A61: присутствия файлов мало — размер и хеш проверяются по-настоящему.
|
||||
|
||||
Найдено живым прогоном на Windows: сборка может прерваться на середине и
|
||||
оставить усечённый файл, а checksums.txt и сам установщик — разойтись ещё
|
||||
до всякой публикации. Проверка одного присутствия этого не ловит.
|
||||
"""
|
||||
release_gate = _load_release_gate()
|
||||
|
||||
# 1. Настоящий размер, настоящий хеш — проходит.
|
||||
good = tmp_path / "dist_good"
|
||||
good.mkdir()
|
||||
_write_installer_with_checksum(good, "HermesHubSetup.exe", b"X" * release_gate.MIN_PACKAGE_BYTES)
|
||||
ok, msg = release_gate.check_publishable_assets(good)
|
||||
assert ok is True, msg
|
||||
assert "SHA-256 сошёлся" in msg
|
||||
|
||||
# 2. Файл меньше нижней границы — похоже на прерванную сборку.
|
||||
truncated = tmp_path / "dist_truncated"
|
||||
truncated.mkdir()
|
||||
_write_installer_with_checksum(truncated, "HermesHubSetup.exe", b"x" * 10)
|
||||
ok, msg = release_gate.check_publishable_assets(truncated)
|
||||
assert ok is False, "усечённый установщик признан пригодным к публикации"
|
||||
assert "мал" in msg
|
||||
|
||||
# 3. Хеш в checksums.txt не совпадает с настоящим файлом.
|
||||
mismatched = tmp_path / "dist_mismatch"
|
||||
mismatched.mkdir()
|
||||
content = b"Y" * release_gate.MIN_PACKAGE_BYTES
|
||||
(mismatched / "HermesHubSetup.exe").write_bytes(content)
|
||||
(mismatched / "checksums.txt").write_text("0" * 64 + " HermesHubSetup.exe\n", encoding="utf-8")
|
||||
ok, msg = release_gate.check_publishable_assets(mismatched)
|
||||
assert ok is False, "разошедшийся хеш признан пригодным к публикации"
|
||||
assert "не сошёлся" in msg
|
||||
|
||||
# 4. checksums.txt не называет установленный файл вовсе.
|
||||
unnamed = tmp_path / "dist_unnamed"
|
||||
unnamed.mkdir()
|
||||
(unnamed / "HermesHubSetup.exe").write_bytes(b"Z" * release_gate.MIN_PACKAGE_BYTES)
|
||||
(unnamed / "checksums.txt").write_text("0" * 64 + " other-file.bin\n", encoding="utf-8")
|
||||
ok, msg = release_gate.check_publishable_assets(unnamed)
|
||||
assert ok is False, "checksums.txt без нужной строки признан достаточным"
|
||||
|
|
|
|||
|
|
@ -72,15 +72,27 @@ def test_seq_token_prevents_stale_refresh_clobber():
|
|||
seq_fresh = store.next_seq()
|
||||
snap_fresh = store.refresh(force_scan=False, seq=seq_fresh)
|
||||
gen_fresh = snap_fresh.generation
|
||||
skipped_before = store.refresh_skipped_total
|
||||
|
||||
# Simulate a delayed/stale response from an earlier seq counter
|
||||
seq_stale = seq_fresh - 1
|
||||
snap_after_stale = store.refresh(force_scan=False, seq=seq_stale)
|
||||
|
||||
# Stale response must be rejected, retaining the fresh generation
|
||||
assert snap_after_stale.generation == gen_fresh
|
||||
# Устаревший ответ должен быть отброшен.
|
||||
#
|
||||
# Проверяется именно отбрасывание, а не равенство поколений. HubStateStore —
|
||||
# процессный синглтон, и фоновый сборщик квот, оставшийся от другого теста,
|
||||
# успевает поднять generation между двумя вызовами. Прежнее
|
||||
# `generation == gen_fresh` падало на этом с «assert 32 == 31» — примерно раз
|
||||
# на десяток прогонов, только при случайном порядке тестов. Инвариант же
|
||||
# другой: устаревший ответ отбрасывается, состояние назад не откатывается.
|
||||
assert store.refresh_skipped_total == skipped_before + 1, (
|
||||
"устаревший ответ должен быть отброшен ровно один раз"
|
||||
)
|
||||
assert snap_after_stale.generation >= gen_fresh, (
|
||||
"состояние откатилось назад: устаревший ответ затёр более свежее"
|
||||
)
|
||||
assert snap_after_stale.seq != seq_stale
|
||||
assert store.refresh_skipped_total >= 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
|
|
|||
Loading…
Reference in a new issue