diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 3638591..3fa4d14 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -18,6 +18,7 @@ - **Notifications / Live-tracker** (`src/notifications.py`, ORCH-042/ORCH-067) — ОДНА live-карточка на задачу (`update_task_tracker`), обновляется на каждом переходе. Режим `ORCH_TRACKER_MODE` (дефолт `bump` с ORCH-067: delete+silent send+repoint внизу чата; `edit` — правка на месте). Карточка несёт строку Plane-статуса `📍 …` (оффлайн-ядро `plane_status_label` + best-effort live-overlay `_live_plane_branch_override`, kill-switch `ORCH_TRACKER_LIVE_STATUS`) и кликабельный номер задачи (`plane_issue_link`/`link_for` → ссылка в Plane, fail-safe на сырой номер). **ORCH-080:** оба низкоуровневых примитива (`send_telegram`/`edit_telegram`) шлют payload с `disable_web_page_preview: True` — Telegram больше не разворачивает баннер link-preview Plane под карточкой/уведомлениями; `parse_mode: HTML` сохранён (ссылка остаётся кликабельной), безусловно без kill-switch. Все алерты, упоминающие `work_item_id`, делают номер кликабельным. **ORCH-087:** bump ведёт авторитетный леджер всех созданных карточек (`tracker_messages`, `deleted_at IS NULL` = жива) и на каждом обновлении зачищает ВСЕ незакрытые mid (а не только скаляр `tracker_message_id`) → класс «замёрзшая сирота» устранён; строка стадии несёт фактический эффорт рядом с моделью (`· {model} · {effort}`, колонка `agent_runs.effort`, стамп в `launcher._spawn`); done-строка времени переписана на три подписанных метрики `⏱️ Агенты · твоё{~cap} · общее с ожиданием` (кап `ORCH_TRACKER_BRD_REVIEW_CAP_S`); deploy-цикл дополнен overlay-ключом `confirm_deploy`. **ORCH-091 (индикация-only):** три корректности рендера — (1) `_STAGE_STATUS_LABEL` покрывает ВСЕ ключи `STAGE_TRANSITIONS` (добавлены `deploy-staging`→«Deploying (staging)», `cancelled`→«Cancelled»; полнота гарантируется тестом по `stages.STAGE_TRANSITIONS`, не статичным списком — NFR-3), runtime-фолбэк для неизвестной стадии стал нейтральным (капитализированное имя) вместо «To Analyse»; (2) при откате конвейера `✅`-строки стадий ПОЗЖЕ текущей позиции (позиция — из порядка `STAGE_TRANSITIONS`, с нормализацией `deploy-staging→deploy` только в гейте подавления; `is_active_stage` не тронут) больше не рисуются; (3) строка стадии суммирует ВСЕ `agent_runs` агента (Σ cost/токены/время теми же формулами, что блок тоталов) → строгая сходимость с `SUM(agent_runs)`. Только `src/notifications.py` + тесты; `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД/транспорт — не тронуты. Контракт всего компонента — never raises; карточка всегда silent. **ORCH-095 (HTML-безопасность данных):** текст карточки шлётся с `parse_mode=HTML`; каждый **data**-слот (длительности `_fmt_minutes`/`_capped_review_str`, статус-лейбл, модель/эффорт, токены/стоимость) экранируется `html.escape` ровно один раз на границе рендера, **markup**-слоты (`num_html`/`link_for`/`_done_link`/`esc_title`) — нет (двойное экранирование запрещено). Устранён класс «неэкранированные данные в HTML» (литерал `<1м` от `_fmt_minutes` → Telegram `400 can't parse entities` → застывшая карточка, инцидент ORCH-093); `_fmt_minutes` по-прежнему даёт `<1м` (escape рендерит визуально идентично). Застрявшая карточка в окне авто-восстанавливается следующим рендером; `edit_telegram`/`update_task_tracker`/леджер сирот не тронуты. Детали — [internals.md](internals.md) §7, [ADR-087](../work-items/ORCH-087/06-adr/ADR-001-tracker-orphan-cleanup.md), [ORCH-091 ADR-001](../work-items/ORCH-091/06-adr/ADR-001-tracker-status-rollback-metrics.md) и [ORCH-095 ADR-001](../work-items/ORCH-095/06-adr/ADR-001-html-safe-card-data-render.md). - **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту. - **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane. Резолв статусов проекта `get_project_states` (ORCH-10) кэширует `{logical_key→uuid}` per-project; **ORCH-068** добавляет в кэш-запись `{uuid→group}` (для терминал-исключения F-2) и **TTL** `ORCH_PLANE_STATES_TTL_S` (дефолт 300с; `0` → прежний lifetime-кэш) — устаревший набор статусов самозалечивается без рестарта процесса через существующий `reload_project_states()` (баг кэша после появления нового Plane-статуса). Форма возврата `get_project_states` неизменна (обратная совместимость). +- **FS ownership detect** (`src/fs_normalize.py`, ORCH-057 — [adr-0031](adr/adr-0031-legacy-ownership-normalization.md)) — чистый **never-raise** leaf (паттерн `serial_gate`/`preflight`), закрывает пробел ORCH-040: при миграции на `user: "1000:1000"` legacy `root:root` файлы в `/repos` ломали создание worktree под uid 1000 (`ensure_worktree` → сырой `fatal: … Permission denied`, агент не стартовал). Три слоя: (1) **D1** — `src/git_worktree.py::ensure_worktree` классифицирует класс «нет прав» (`Permission denied`/`could not create leading directories`/`insufficient permission`/`EACCES`/`EPERM`) и поднимает actionable `RuntimeError` с причиной + лечащей командой (не-прав-ошибки сохраняют прежний контракт — меняется только формулировка, не факт сбоя); (2) **D2** — `scan_ownership(roots, target_uid=os.getuid())` обходит `/repos/_wt`, `/.git/{objects,worktrees}`, `data/runs` с ранним выходом при первом `st_uid != target_uid` + TTL-кэш; (3) **D3** — best-effort вызов на старте `main.lifespan` → WARNING + Telegram при mismatch (claim **НЕ** блокируется — внятный ранний отказ даёт D1 в точке launch, знающей repo; preflight-блок отвергнут как repo-слепой → регресс enduro). Опц. `normalize()` chown'ит только при `CAP_CHOWN` (под uid 1000 — no-op; init-контейнер/root-entrypoint отвергнуты — реинтродукция root-контекста + self-deploy compose). Фактическая нормализация = **операторская процедура** под root на хосте (`INFRA.md` «Миграция uid»). Условность `applies(repo)` first: `fs_normalize_enabled` (kill-switch) + `fs_normalize_repos` (CSV, пусто → self-hosting only). Наблюдаемость — блок `fs_ownership` в `GET /queue`; опц. `POST /fs-normalize/check`. `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — не тронуты. Детали — `docs/work-items/ORCH-057/06-adr/ADR-001-legacy-ownership-normalization.md`. - **Metrics endpoint** (`src/metrics.py` + `GET /metrics`, ORCH-099 — [adr-0030](adr/adr-0030-metrics-endpoint.md)) — лёгкий **read-only** leaf-сборщик (`build_metrics() -> dict`, never-raise по разделам, паттерн `serial_gate.snapshot()`) + тонкий эндпоинт (стиль `GET /queue`). Отдаёт JSON-«сырьё» о самом орке (стадии задач / очередь jobs / agent-liveness / стоимость-токены) как **стабильный машинный контракт для sidecar F1b** (`watchdog/`, отдельная задача — наблюдатель отделён от наблюдаемого). Только чтение существующих `tasks`/`jobs`/`agent_runs` + in-memory-снапшотов (`worker.breaker`); два read-only helper'а в `db.py` (`get_running_agents`/`agent_cost_totals`). Логику мониторинга (пороги/алерты/история/Telegram) НЕ несёт — это F1b. Контракт ниже (§ «Сырьё-эндпоинт `/metrics`»). Kill-switch `metrics_endpoint_enabled` (дефолт `True`). `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — не тронуты. ## Сырьё-эндпоинт `/metrics` для sidecar (ORCH-099 — design) diff --git a/docs/architecture/adr/adr-0031-legacy-ownership-normalization.md b/docs/architecture/adr/adr-0031-legacy-ownership-normalization.md new file mode 100644 index 0000000..9f56e2c --- /dev/null +++ b/docs/architecture/adr/adr-0031-legacy-ownership-normalization.md @@ -0,0 +1,93 @@ +--- +work_item: ORCH-057 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# adr-0031: Нормализация legacy root-owned файлов при миграции uid — детект-leaf + actionable worktree-ошибка + +- **Статус:** proposed +- **Дата:** 2026-06-10 +- **Задача:** ORCH-057 (follow-up ORCH-040) +- **Детальный ADR:** `docs/work-items/ORCH-057/06-adr/ADR-001-legacy-ownership-normalization.md` + +## Контекст +ORCH-040 перевёл контейнеры на `user: "1000:1000"`, изменив только `docker-compose.yml`. Владельца +уже существующих `root:root` файлов в bind-mount `/repos` это не меняет. Под uid 1000 +`src/git_worktree.py::ensure_worktree` (`os.makedirs` стр. 78 / `git worktree add` стр. 81/85) не может +создать worktree рядом с root-owned `/repos/_wt/` → `fatal: could not create leading directories … +Permission denied`, который сейчас пробрасывается сырым. Конвейер приходит сюда из +`launcher._spawn`/`_materialize_deferred_branch` (ORCH-088) — **агент не стартует** (launch-time +инфра-сбой, не код задачи). Инцидент 06.06 на проде (первый запуск ORCH-043); workaround Стрима +(`chown -R 1000:1000`) наложен вручную. ADR-040 описал нормализацию абстрактно («вне объёма кода») и +не дал процедуры → баг воспроизводим на чистой среде / новом репо / после исторического запуска под +root. Контейнер бежит **без root** → код физически не может `chown` чужие файлы; ему доступны лишь +детект + диагностика. + +## Решение +Три аддитивных, обратимых kill-switch'ем слоя — паттерн условного leaf-гейта (`coverage_gate`/ +`serial_gate`) + best-effort startup-хук (`main.lifespan`, как lease-reclaim). `STAGE_TRANSITIONS` / +`QG_CHECKS` / `check_*` / machine-verdict-ключи (`verdict:`/`result:`/`deploy_status:`/ +`staging_status:`/`security_status:`/`coverage_status:`) / схема БД — **байт-в-байт прежние**. + +- **Actionable worktree-ошибка (D1):** `ensure_worktree` классифицирует класс «нет прав» (маркеры + `Permission denied`/`could not create leading directories`/`insufficient permission`/`EACCES`/ + `EPERM`) и поднимает `RuntimeError` с причиной (legacy root-файлы после миграции uid) + лечащей + командой + ссылкой на INFRA.md. Не-прав-ошибки сохраняют прежний текст/смысл (никакой подмены). + Меняется лишь **формулировка**, не факт сбоя. +- **Детект-leaf `src/fs_normalize.py` (D2):** чистый, never-raise, TTL-кэш (паттерн `preflight`). + `scan_ownership(roots, target_uid)` обходит `/repos/_wt`, `/.git/objects`, + `/.git/worktrees`, `data/runs`; ранний выход при первом `st_uid != target_uid` + (`target_uid=os.getuid()` по умолчанию). `applies(repo)` (kill-switch + scope; пусто → + `is_self_hosting_repo`) проверяется ПЕРВЫМ → дорогой обход только при applies. Идемпотентно; + ошибка обхода → WARNING + консервативный `mismatch=False`. +- **Интеграция = наблюдаемость, без блокировки claim (D3):** best-effort `scan_ownership()` на старте + `main.lifespan` → WARNING + Telegram при mismatch. Claim НЕ гейтится: внятный ранний отказ даёт D1 + в точке launch (знает repo, агент ещё не тратил токены). Блокирующий preflight-гейт отвергнут — + preflight не знает repo, заблокировал бы и enduro-trails на общем `/repos`. +- **Опц. `normalize()` (D4):** chown только при `CAP_CHOWN`/root (под uid 1000 — no-op + лог), + флаг `fs_normalize_auto` (дефолт `False`). Init-контейнер/root-entrypoint отвергнут: реинтродукция + root-контекста (анти-цель ORCH-040) + правка compose = self-deploy/групповой риск. Реальную + нормализацию несёт операторская процедура. +- **Процедура (D5):** `INFRA.md` получает раздел «Миграция uid: обязательная нормализация legacy + root-файлов» (точные команды по всем корням) как обязательный шаг миграции; forward-breadcrumb из + ADR-040. +- **Флаги:** `fs_normalize_enabled` (kill-switch, дефолт `True`), `fs_normalize_repos` (CSV, пусто → + self-hosting only), `fs_target_uid` (1000), `fs_normalize_auto` (`False`), `fs_scan_roots`, + `fs_scan_cache_ttl_s` (300). Наблюдаемость — блок `fs_ownership` в `GET /queue`; опц. `POST + /fs-normalize/check`. + +## Альтернативы +- **Init-контейнер/root-entrypoint** — реинтродукция root (анти-цель ORCH-040), self-deploy compose, + групповой риск ради разовой операции. Отвергнуто; носитель нормализации — операторская процедура. +- **Блокирующий claim-гейт (preflight)** — preflight не знает repo → регресс enduro на общем `/repos`. + Отвергнуто. +- **Блокирующий claim-гейт (queue_worker/claim)** — дорогой FS-обход в hot-path + «молчаливое + зависание» вместо диагноза D1. Отвергнуто. +- **Авто-chown из app по умолчанию** — под uid 1000 невозможен; ложное ожидание самолечения. + Отвергнуто (оставлен opt-in `fs_normalize_auto`). +- **Hard-fail старта при mismatch** — нарушает never-raise, стопорит сервис всех проектов. Отвергнуто. + +## Последствия +- Класс «сырой git-fatal на launch после миграции uid» закрыт внятным диагнозом (D1) + проактивным + startup-сигналом (D3); пробел процедуры ADR-040 закрыт (INFRA.md). +- Нулевая регрессия enduro-trails (scope first); инварианты конвейера/схема БД — байт-в-байт. +- Никакого root-контекста/рестарта прода/касания `main`/force-push/прод-образа (NFR-1). +- Плата: фактический `chown` остаётся ручным операторским шагом (но теперь внятным, с инструкцией); + +1 best-effort startup-хук и leaf-модуль; `fs_normalize_auto=True` под root реинтродуцирует + chown-контекст (дефолт `False`, не для прод-self). +- Аддитивно/обратимо: **не** `arch:major-change` (нет новой стадии/QG/таблицы/смены топологии) — leaf + + startup-хук + docs. +- **Откат:** `fs_normalize_enabled=False` → полный no-op (мгновенный обратимый kill-switch). + +## Связи +adr-0005 (контейнер под host-uid — порождающее решение ORCH-040, чей пробел закрываем), +adr-0029/adr-0012 (coverage/security-гейт — паттерн условного leaf `applies`/scope/never-raise/ +fail-open), adr-0017 (serial-gate — leaf never-raise + отложенный срез ветки `_materialize_deferred_ +branch`, чья точка падает в `ensure_worktree`), adr-0011 (job-reaper — образец best-effort +startup-хука в `lifespan`), adr-0024 (disk-watchdog — образец «только читать/уведомлять, не трогать +хост/прод»). + diff --git a/docs/operations/INFRA.md b/docs/operations/INFRA.md index 8866eee..41cc28f 100644 --- a/docs/operations/INFRA.md +++ b/docs/operations/INFRA.md @@ -47,8 +47,35 @@ ADR `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md` и гл - **P-3:** `id slin` → `1000:1000`; `/repos`, `/app/data` уже `1000:1000`. - **P-4:** прод-рестарт self — только в окно тишины (`GET /status` без активных задач): общий инстанс с enduro-trails. -- Разовый разгребающий `chown -R 1000:1000 /home/slin/repos/orchestrator` для старых - `root:root` файлов из истории (вне объёма кода). +- **P-5 (блокер миграции uid, ORCH-057):** нормализация **всех** legacy `root:root` файлов в `/repos` + — см. подраздел «Миграция uid: обязательная нормализация legacy root-файлов» ниже. Без неё первый + job падает на launch при создании worktree (инцидент 06.06, ORCH-043). + +### Миграция uid: обязательная нормализация legacy root-файлов (ORCH-057) +ORCH-040 сменил `user:` контейнера, но **не** владельца уже существующих файлов в bind-mount `/repos`, +созданных прежним root-контейнером. Под uid 1000 `src/git_worktree.py::ensure_worktree` не может +создать worktree рядом с `root:root` каталогом `/repos/_wt/` → `fatal: could not create leading +directories … Permission denied` (агент даже не стартует). С ORCH-057 эта ошибка распознаётся и +выдаётся **внятно** (с лечащей командой) + детектится на старте сервиса (WARNING/Telegram, блок +`fs_ownership` в `GET /queue`), но **фактический `chown` обязан выполнить оператор под root на хосте** +(контейнер бежит без root и chown'ить чужие файлы не может). + +**Обязательный разовый шаг при миграции uid / на новой среде (под root на mva154, ПЕРЕД стартом app):** +```bash +# 1) worktree-корень (все ветки всех проектов режутся здесь) +sudo chown -R 1000:1000 /home/slin/repos/_wt +# 2) .git обоих репо (objects / worktrees-административные записи) +sudo chown -R 1000:1000 /home/slin/repos/orchestrator/.git \ + /home/slin/repos/enduro-trails/.git +# 3) корень orchestrator целиком (включая data/runs/*.log — 37 root-логов в инциденте) +sudo chown -R 1000:1000 /home/slin/repos/orchestrator +# Проверка (пусто = ок): +find /home/slin/repos/_wt ! -uid 1000 -print -quit +``` +Процедура **идемпотентна** (повтор на корректной среде — no-op) и входит в **чеклист деплоя/миграции +self**. Область охвата: `_wt`, оба `.git` (`objects`+`worktrees`), `data/runs`. См. +`docs/work-items/ORCH-057/06-adr/ADR-001-legacy-ownership-normalization.md` и сквозной +`docs/architecture/adr/adr-0031-legacy-ownership-normalization.md`. ### Тома (volumes) - `./data` → `/app/data` (БД; у staging — `./data/staging`) diff --git a/docs/work-items/ORCH-057/06-adr/ADR-001-legacy-ownership-normalization.md b/docs/work-items/ORCH-057/06-adr/ADR-001-legacy-ownership-normalization.md new file mode 100644 index 0000000..d466bf7 --- /dev/null +++ b/docs/work-items/ORCH-057/06-adr/ADR-001-legacy-ownership-normalization.md @@ -0,0 +1,212 @@ +--- +work_item: ORCH-057 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# ADR-001: Нормализация legacy root-owned файлов при миграции на uid 1000 — детект + actionable-ошибка + процедура + +Work Item: **ORCH-057** — follow-up ORCH-040 (legacy `root:root` файлы в `/repos` ломают создание worktree под uid 1000) +Стадия: **architecture** +Сквозная регистрация: **`docs/architecture/adr/adr-0031-legacy-ownership-normalization.md`** (новый +leaf-компонент + startup-поведение, затрагивает весь инстанс → кросс-каттинг). + +## Статус +Proposed + +## Контекст + +ORCH-040 перевёл оба контейнера на `user: "1000:1000"`, изменив **только** `docker-compose.yml`. +Смена `user:` не меняет владельца уже существующих файлов, созданных прежним root-контейнером. +Bind-mount `/home/slin/repos → /repos` содержал `root:root` каталоги (`_wt/`, старые worktree, +`.git/objects`, `data/runs` — 37 root-логов). + +**Сверено по коду:** +- `src/git_worktree.py::ensure_worktree` (стр. 78 `os.makedirs(os.path.dirname(wt))`, стр. 81/85 + `git worktree add`) — точка реального падения. При `root:root` владельце `/repos/_wt/` uid 1000 + не может создать рядом новый каталог worktree → `fatal: could not create leading directories … + Permission denied`. Сейчас этот stderr пробрасывается «сырым» в `RuntimeError` (стр. 90–93) без + диагноза причины. +- Конвейер приходит сюда из `src/agents/launcher.py::_spawn` и `_materialize_deferred_branch` + (ORCH-088, отложенный срез ветки на момент claim analyst-job). **Агент не стартует** — падает + создание worktree (НЕ код задачи), т.е. это launch-time инфраструктурный сбой. +- Контейнер бежит под numeric uid 1000 **без root** (ORCH-040 P-3, ORCH-058 реальный user `slin` + в образе). Под uid 1000 `chown` чужих (root) файлов **невозможен** без `CAP_CHOWN`. Значит код + физически не может «починить» права сам — ему доступны только **детект + диагностика**, а + фактический `chown` — операторская процедура. +- ADR-001 ORCH-040 упоминал «массовый chown старых root-файлов» лишь абстрактно («вне объёма кода», + «разовая операция Owner») и не дал конкретной процедуры → deployer её не выполнил → баг проявился + в проде 06.06 на первом запуске ORCH-043. Прод сейчас рабочий (ручной workaround Стрима наложен), + но проблема **воспроизводится** на чистой среде / новом репо / после любого исторического запуска + под root. + +Это **закрытие недоделанного AC ORCH-040**, а не новая фича. Существующие гейты/паттерны для опоры: +условный leaf-гейт `coverage_gate`/`serial_gate` (kill-switch + scope + `is_self_hosting_repo`), +best-effort startup-хуки в `main.lifespan` (lease-reclaim, log-rotation — never-fatal), +read-only снимки `GET /queue` (`serial_gate.snapshot()`), TTL-кэш `preflight._cache`. + +## Решение + +### Сводка +Три аддитивных, обратимых kill-switch'ем слоя, **без** изменения `STAGE_TRANSITIONS` / `QG_CHECKS` / +`check_*` / machine-verdict-ключей / схемы БД: + +1. **Actionable-ошибка** в `ensure_worktree` — класс «нет прав на создание worktree» распознаётся и + превращается в диагностируемый `RuntimeError` с причиной + лечащей командой (FR-1). +2. **Детект-леаф** `src/fs_normalize.py` — чистый, never-raise, TTL-кэшируемый обход корней, ищет + файлы с `uid != target_uid` (FR-2); вызывается best-effort на старте сервиса с наблюдаемостью + (FR-3). +3. **Операторская процедура** в `INFRA.md` + forward-breadcrumb из ADR-040 — точные команды разовой + нормализации как обязательный шаг миграции uid (FR-5). + +Фактический `chown` остаётся **операторской процедурой** (NFR-1: код под uid 1000 без root его делать +не может и не должен). + +### D1 — `ensure_worktree`: классификация отказа доступа (FR-1, AC-1, AC-2) +Оборачиваем **обе** точки сбоя по правам — `os.makedirs(os.path.dirname(wt))` (стр. 78) и оба +`git worktree add` (стр. 81/85). Класс «нет прав» детектируется по маркерам в `stderr`/исключении: +`Permission denied`, `could not create leading directories`, `insufficient permission for adding an +object`, `PermissionError` (errno `EACCES`/`EPERM`). При совпадении — `RuntimeError`, текст которого: +(а) называет корневую причину («legacy root-owned файлы в `/repos/_wt` или `.git` после миграции uid +ORCH-040»); (б) указывает лечащую команду (`chown -R : /repos/_wt …`) и ссылку на +раздел INFRA.md; (в) **не** является сырым git stderr. + +**Инвариант контракта (AC-2 FAIL-условие):** ошибки, **не** связанные с правами (реальный git-конфликт, +отсутствие `origin/main`, таймаут), сохраняют **прежний** текст/смысл — никакой подмены. Классификатор — +чистая функция `classify_worktree_error(stderr_or_exc) -> bool` (или хелпер в `fs_normalize`), +покрытая юнит-тестами на обе ветки. Помощь-сообщение строится только при `True`. Это **меняет лишь +формулировку** ошибки, не её факт (NFR-3): worktree как падал, так и падает — но теперь внятно. + +### D2 — Детект-леаф `src/fs_normalize.py` (FR-2, AC-3) +Новый чистый модуль по образцу `serial_gate`/`post_deploy` (импортирует только `config`/`logging`/ +`os`/`pwd`; не тянет `stage_engine`/`launcher`). API: + +- `scan_ownership(roots: list[str] | None = None, target_uid: int | None = None) -> OwnershipScan` — + обходит корни, возвращает `{mismatch: bool, target_uid: int, roots_checked: list, roots_mismatch: + list, sample_path: str | None, count: int | None, checked_at: float}`. +- **`target_uid`** по умолчанию = `os.getuid()` (uid, под которым реально бежит процесс — ровно тот + субъект, что «не может создать файл»); переопределяется `fs_target_uid` (дефолт 1000) для тестов/ + нестандартного рантайма. +- **Корни** по умолчанию: `/repos/_wt`, `/.git/objects`, `/.git/worktrees` (для репо из + скоупа), `data/runs` (`os.path.dirname(settings.db_path)/runs`). Переопределяемы `fs_scan_roots` + (CSV). +- **Дешевизна (риск стоимости обхода):** **ранний выход при первом mismatch** (для быстрого булева + вердикта `os.lstat(...).st_uid != target_uid`). Полный `count` — опционален/семплирован (отдельный + дешёвый режим, по умолчанию выключен), чтобы не обходить целиком большие `.git/objects`. Результат + **кэшируется по TTL** `fs_scan_cache_ttl_s` (паттерн `preflight._cache`, `force=` обходит кэш). +- **never-raise (NFR-3):** любая ошибка обхода (исчезнувший путь, отказ stat) → деградирует в WARNING + и консервативный вердикт `mismatch=False` (не блокирует и не паникует); идемпотентно (AC-3: + повторный скан на чистой среде — `mismatch=False`, no-op). +- **`applies(repo: str) -> bool`** — `fs_normalize_enabled` (kill-switch) И scope (`fs_normalize_repos` + CSV; пусто → `is_self_hosting_repo(repo)`, как `coverage_gate`); проверяется **ПЕРВЫМ**, дорогой + обход — только при `applies==True` (NFR-2: enduro-trails не сканируется при пустом CSV). +- **`snapshot() -> dict`** — read-only для `GET /queue`. + +### D3 — Точка интеграции: startup-наблюдаемость, БЕЗ блокировки claim (FR-3 — разрешение открытого выбора TRZ) +TRZ §2 оставил архитектору выбор «preflight vs queue_worker» для опц. гейта claim'а. **Решение: +claim НЕ блокируем.** + +- **Startup (`main.lifespan`):** best-effort вызов `scan_ownership()` рядом с lease-reclaim/log-rotation + (стр. 63–90), обёрнут `try/except` (never-fatal). При `mismatch` — структурный WARNING (число/корни/ + лечащая команда) + Telegram (если включён). Это даёт оператору **проактивный сигнал заранее** + (AC-4), не дожидаясь падения задачи. +- **«Внятно и заранее» обеспечивает D1, а не claim-гейт.** `ensure_worktree` знает `repo` и падает + до того, как агент потратит хоть один токен (агент не стартует). Это и есть требуемый ранний внятный + исход. + +**Почему НЕ блокирующий claim-гейт (отвергнуто):** +- `preflight.check()` **не знает repo** и гейтит claim **всех** репо → при mismatch в общем `/repos/_wt` + заблокировал бы и enduro-trails (нарушение NFR-2 при включённом флаге). Сделать его scope-aware + внутри preflight нельзя без знания репо в точке вызова. +- Гейт в `queue_worker`/`db.claim_next_job` (как `serial_gate`) технически scope-aware, но: (1) + оставил бы задачу «молча висеть» в очереди вместо явного диагноза; (2) добавил бы дорогой FS-обход + в offline hot-path claim'а; (3) дублировал бы исход, который D1 уже даёт внятно. Лишняя поверхность + без выигрыша. + +Итог: **детект = наблюдаемость (startup + опц. ручной POST), а внятный отказ = D1 в точке launch.** + +### D4 — Опциональная авто-нормализация `normalize()` (FR-4) — не init-контейнер +`fs_normalize.normalize(roots, target_uid)` выполняет `os.chown`/`chown -R` по корням **только если +процесс имеет `CAP_CHOWN`/root**. Под uid 1000 без прав — **no-op + честный лог** «нужна операторская +процедура» (НЕ ошибка). Включается отдельным флагом `fs_normalize_auto` (дефолт `False` — детект-only). + +**Init-контейнер/root-entrypoint отвергнут (см. Альтернативы):** он (а) реинтродуцирует root-контекст, +ровно который ORCH-040 убрал ради безопасности; (б) требует правки `docker-compose.yml`/entrypoint → +**self-deploy** с групповым риском (NFR-1) и обязательной staging-страховкой ради разовой задачи; +(в) discretionary по BRD §2 «Опционально». Носитель реальной нормализации — **документированная +операторская процедура** (D5), запускаемая под root **на хосте** один раз при миграции uid. + +### D5 — Процедура в INFRA.md + forward-breadcrumb (FR-5, AC-7) +В `docs/operations/INFRA.md` (раздел «Рантайм-uid (ORCH-040)») добавляется подраздел **«Миграция uid: +обязательная нормализация legacy root-файлов»** с точными командами, покрывающими **все** корни +(`_wt`, оба `.git`, `data/runs`), помеченный как **обязательный** шаг миграции uid и пункт чеклиста +деплоя self. Существующий абстрактный буллет (стр. 50–51) заменяется ссылкой на новый подраздел. +В ADR-040 — необязательный forward-breadcrumb на ORCH-057 (история ORCH-040 не переписывается, §2 BRD). + +### D6 — Конфиг-флаги (TRZ §7) и наблюдаемость +Аддитивно в `src/config.py` (существующие значения не трогаются): + +| Флаг (env) | Дефолт | Смысл | +|------------|--------|-------| +| `fs_normalize_enabled` (`ORCH_FS_NORMALIZE_ENABLED`) | `True` | kill-switch; `False` → весь код инертен, поведение 1:1 как до ORCH-057 (D1 тоже гардится — при выкл. контракт ошибки прежний) | +| `fs_normalize_repos` (`ORCH_FS_NORMALIZE_REPOS`) | `""` | scope CSV; пусто → self-hosting only (`is_self_hosting_repo`) | +| `fs_target_uid` (`ORCH_FS_TARGET_UID`) | `1000` | целевой uid (фолбэк, если `os.getuid()` неприменим) | +| `fs_normalize_auto` (`ORCH_FS_NORMALIZE_AUTO`) | `False` | детект-only; `True` → попытка chown при наличии прав (D4) | +| `fs_scan_roots` (`ORCH_FS_SCAN_ROOTS`) | `""` | CSV-переопределение корней | +| `fs_scan_cache_ttl_s` (`ORCH_FS_SCAN_CACHE_TTL_S`) | `300` | TTL детект-кэша | + +Наблюдаемость (AC-4): read-only блок `fs_ownership` в `GET /queue` (`snapshot()`: +`{enabled, target_uid, mismatch, roots_checked, roots_mismatch, checked_at}`); опц. ручной триггер +`POST /fs-normalize/check` (форс-пересчёт, по образцу `POST /serial-gate/unfreeze`). Telegram при +mismatch — с кликабельным номером задачи (если в контексте есть `work_item_id`), числом/корнями, +лечащей командой. + +## Альтернативы +- **Init-контейнер / root-entrypoint, выполняющий `chown` на буте** — отвергнуто: реинтродуцирует + root-контекст (анти-цель ORCH-040), требует правки `docker-compose.yml`/entrypoint = self-deploy + + групповой риск + обязательная staging-страховка ради одноразовой операции; BRD помечает его + «Опционально». Реальную нормализацию несёт документированная разовая операторская процедура. +- **Блокирующий claim-гейт в `preflight`** — отвергнуто: preflight не знает repo → блокирует claim + ВСЕХ репо, регресс enduro-trails на общем `/repos` (нарушение NFR-2). +- **Блокирующий claim-гейт в `queue_worker`/`claim_next_job`** — отвергнуто: дорогой FS-обход в + offline hot-path, «молчаливое зависание» вместо внятного диагноза, дублирует исход D1. +- **Авто-`chown` из app-кода по умолчанию** — отвергнуто: под uid 1000 невозможен; включение по + умолчанию создавало бы ложное ожидание самолечения. Оставлен как opt-in `fs_normalize_auto` для + сред, где процесс имеет CAP_CHOWN. +- **Жёсткий fail на старте при mismatch** — отвергнуто: нарушает never-raise (NFR-3) и мог бы + застопорить старт сервиса всех проектов из-за грязного `/repos`. Детект — only WARNING/Telegram. + +## Последствия +- **+** Класс «сырой git-fatal на launch после миграции uid» закрыт: оператор получает внятный + диагноз + лечащую команду в точке падения (D1) и проактивный сигнал на старте (D3). +- **+** Воспроизводимая процедура в INFRA.md закрывает пробел ADR-040 (AC-7). +- **+** Нулевая регрессия enduro-trails (scope `applies()` first, пустой CSV → self-hosting only); + `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — байт-в-байт прежние (AC-6). +- **+** Никакого root-контекста, рестарта прода, касания `main`/force-push/прод-образа (NFR-1, AC-5). +- **−** Фактический `chown` остаётся **ручным** операторским шагом — на средах, где его забыли, баг + всё ещё проявится, но теперь **внятно** (с инструкцией), а не сырым git-fatal. Митигейшн: + startup-WARNING+Telegram + обязательный пункт чеклиста миграции в INFRA.md. +- **−** Ещё один best-effort startup-хук + leaf-модуль (рост поверхности). Митигейшн: чистый + never-raise leaf, TTL-кэш, ранний выход обхода, kill-switch. +- **−** `fs_normalize_auto=True` под root реинтродуцирует chown-контекст — поэтому дефолт `False` и + он не для прод-self (прод бежит под uid 1000). +- **Откат:** `fs_normalize_enabled=False` → весь код инертен (D1 контракт ошибки прежний, детект не + запускается); миграций/правки схемы нет → мгновенный обратимый kill-switch. + +## Ссылки +- BRD: `docs/work-items/ORCH-057/01-brd.md` +- TRZ: `docs/work-items/ORCH-057/02-trz.md` +- Acceptance: `docs/work-items/ORCH-057/03-acceptance-criteria.md` +- Инфра: `docs/work-items/ORCH-057/07-infra-requirements.md` +- Риски: `docs/work-items/ORCH-057/10-tech-risks.md` +- Сквозной ADR: `docs/architecture/adr/adr-0031-legacy-ownership-normalization.md` +- Сверено по коду: `src/git_worktree.py` (`ensure_worktree` стр. 78/81/85/90), `src/preflight.py` + (TTL-кэш), `src/main.py` (`lifespan` стр. 63–114), `src/serial_gate.py` / `src/coverage_gate.py` + (паттерн условного leaf `applies`/scope/`is_self_hosting_repo`). +- Предшественник: `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md`, + `docs/architecture/adr/adr-0005-container-runs-as-host-uid.md`. + + diff --git a/docs/work-items/ORCH-057/07-infra-requirements.md b/docs/work-items/ORCH-057/07-infra-requirements.md new file mode 100644 index 0000000..68212fd --- /dev/null +++ b/docs/work-items/ORCH-057/07-infra-requirements.md @@ -0,0 +1,64 @@ +--- +work_item: ORCH-057 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 07 — Инфра-требования: ORCH-057 — нормализация legacy root-owned файлов при миграции на uid 1000 + +Work Item: **ORCH-057** · Repo: **orchestrator** · Стадия: architecture + +> When-applicable. Топология контейнеров **не меняется** (init-контейнер/правка `docker-compose.yml` +> отвергнуты — ADR-001 D4). Файл фиксирует новые env-флаги и **обязательную операторскую процедуру** +> нормализации legacy root-файлов как шаг миграции uid. + +## I-1. Топология / окружения +**Без изменений.** Контейнеры `orchestrator` (8500) / `orchestrator-staging` (8501), `user: +"1000:1000"`, bind-mount `/home/slin/repos → /repos`, `network_mode: host` — как есть. Init-контейнер +/ root-entrypoint **сознательно НЕ вводятся** (реинтродуцировали бы root-контекст, убранный ORCH-040, +и потребовали бы self-deploy compose с групповым риском — ADR-001 D4, Альтернативы). + +## I-2. Переменные окружения / секреты +Новые env-флаги (аддитивно в `src/config.py`, дефолты сохраняют поведение до ORCH-057). Добавить в +`.env.example` (секретов нет): + +| Env | Дефолт | Назначение | +|-----|--------|------------| +| `ORCH_FS_NORMALIZE_ENABLED` | `true` | kill-switch всего слоя ORCH-057 | +| `ORCH_FS_NORMALIZE_REPOS` | `` (пусто) | scope CSV; пусто → self-hosting only (enduro не затронут) | +| `ORCH_FS_TARGET_UID` | `1000` | целевой uid (фолбэк к `os.getuid()`) | +| `ORCH_FS_NORMALIZE_AUTO` | `false` | детект-only; `true` → попытка chown при наличии CAP_CHOWN | +| `ORCH_FS_SCAN_ROOTS` | `` (пусто) | CSV-переопределение корней обхода | +| `ORCH_FS_SCAN_CACHE_TTL_S` | `300` | TTL детект-кэша | + +Секреты не вводятся. + +## I-3. Деплой / рестарт +- **Self-hosting инвариант (NFR-1):** код задачи **не** рестартит/не роняет прод-контейнер + `orchestrator`, не трогает `main`/force-push/прод-образ. `chown` из кода возможен лишь при наличии + прав (под uid 1000 — no-op). +- Изменение **только** `src/**` + docs → штатный деплой self **через staging-гейт (8501)**, затем + прод-рестарт **в окно тишины** (`GET /status` без активных задач). Правки `docker-compose.yml`/ + entrypoint в задаче **нет** → нет дополнительного инфра-риска сверх обычного self-деплоя. +- **Обязательная операторская процедура нормализации (host-prerequisite миграции uid)** — выполняется + **под root на хосте mva154 один раз** при миграции uid / на новой среде, ПЕРЕД стартом app. + Каноничный текст — в `docs/operations/INFRA.md` (раздел «Миграция uid: обязательная нормализация + legacy root-файлов»). Команды покрывают все корни: + ``` + sudo chown -R 1000:1000 /home/slin/repos/_wt + sudo chown -R 1000:1000 /home/slin/repos/orchestrator/.git \ + /home/slin/repos/enduro-trails/.git + sudo chown -R 1000:1000 /home/slin/repos/orchestrator # incl. data/runs/*.log + # Проверка: find /home/slin/repos/_wt ! -uid 1000 -print -quit (пусто = ок) + ``` + Идемпотентна (повтор на корректной среде — no-op). Помечена обязательным пунктом чеклиста + деплоя/миграции self. + +## I-4. CI/CD +Без изменений в `.gitea/workflows/`. Новые юнит-тесты (`tests/test_fs_normalize.py`, +`tests/test_git_worktree_perm_error.py` — см. `04-test-plan.yaml`) гоняются существующим +`pytest tests/ -q`. Новых системных зависимостей образа нет. + diff --git a/docs/work-items/ORCH-057/10-tech-risks.md b/docs/work-items/ORCH-057/10-tech-risks.md new file mode 100644 index 0000000..eb21dab --- /dev/null +++ b/docs/work-items/ORCH-057/10-tech-risks.md @@ -0,0 +1,38 @@ +--- +work_item: ORCH-057 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-10 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-057 — нормализация legacy root-owned файлов при миграции на uid 1000 + +Work Item: **ORCH-057** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Перечисляет риски реализации и их митигейшн. + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 | **Ложная классификация ошибки worktree** (D1): не-прав-ошибка распознана как «нет прав» → подмена смысла (FAIL AC-2). | Низ. | Сред. | Узкий набор маркеров (`Permission denied`/`could not create leading directories`/`insufficient permission`/`EACCES`/`EPERM`); классификатор — чистая функция с юнит-тестами на обе ветки; не-совпадение → прежний сырой текст без изменений. | +| TR-2 | **Дорогой рекурсивный обход** больших `.git/objects` / `_wt` тормозит старт сервиса. | Сред. | Сред. | Ранний выход при первом mismatch (булев вердикт); полный `count` опционален/семплирован; TTL-кэш (`fs_scan_cache_ttl_s`); вызов best-effort на старте, не в hot-path claim'а; `applies()` first → обход только при applies. | +| TR-3 | **Ложно-блокирующий эффект на enduro-trails** через общий `/repos`. | Низ. | Выс. | Claim НЕ блокируется (D3 — только наблюдаемость); scope `applies()` first, пустой CSV → self-hosting only → enduro не сканируется; детект never-raise. | +| TR-4 | **Забытый ручной `chown`**: на среде без выполненной процедуры баг всё ещё проявится. | Сред. | Сред. | Теперь проявляется **внятно** (D1 actionable-ошибка + startup WARNING/Telegram, не сырой git-fatal); процедура — обязательный пункт чеклиста миграции в INFRA.md; идемпотентна. Остаточный риск принят (код под uid 1000 не может chown). | +| TR-5 | **`fs_normalize_auto=True` под root** реинтродуцирует chown-контекст / неожиданный массовый chown. | Низ. | Сред. | Дефолт `False`; прод-self бежит под uid 1000 (chown = no-op); auto-режим — opt-in для сред с CAP_CHOWN; init-контейнер отвергнут (ADR-001 D4). | +| TR-6 | **never-raise дыра**: необработанное исключение детекта роняет старт сервиса всех проектов. | Низ. | Выс. | Леаф never-raise (паттерн `serial_gate`/`post_deploy`); startup-вызов в `try/except` (как lease-reclaim/log-rotation); ошибка → WARNING + консервативный `mismatch=False`. | +| TR-7 | **`os.getuid()` неприменим** в нестандартном рантайме → неверный target_uid → ложный mismatch. | Низ. | Низ. | Фолбэк `fs_target_uid` (дефолт 1000); идемпотентность скана; вердикт only-наблюдательный (не блокирует). | +| TR-8 | **Кэш устарел** после выполнения нормализации → stale `mismatch=True` в `GET /queue`. | Низ. | Низ. | TTL-инвалидизация; ручной `POST /fs-normalize/check` (`force=True`) для немедленного пересчёта. | + +## Сводный вывод +Доминирующий класс — **операционные риски разовой нормализации**, а не алгоритмические: код только +читает/детектит/диагностирует (chown — операторская процедура под root на хосте). Самостоятельный +техдолг (TR-4) — остаточный и **принят**: контейнер без root физически не может починить права сам; +решение гарантирует **внятность** отказа, а не его отсутствие. Self-hosting-безопасность соблюдена +(никакого рестарта прода / касания `main` / root-контекста в коде). Изменение аддитивно и обратимо +kill-switch'ем → **эскалация `arch:major-change` НЕ требуется** (нет новой стадии/QG/таблицы/смены +топологии). Возврат в анализ не нужен — ТЗ удовлетворяется без нарушения принципов архитектуры. +Остаточный риск для прод-конвейера — **низкий**. +