Compare commits
20 Commits
staging-lo
...
feature/OR
| Author | SHA1 | Date | |
|---|---|---|---|
| bcf5256731 | |||
| 59e47ba067 | |||
| be64761654 | |||
| f81715bd39 | |||
| fe5eb38af2 | |||
| 5436c4110e | |||
| 8e91c8c23c | |||
| 83e26279bf | |||
| 3441f01650 | |||
| 18378c2713 | |||
| 753eea37fc | |||
| efbd8b7b8f | |||
| 6ef28efccd | |||
| 52cfe51bd8 | |||
| 05c17135c1 | |||
| 0ac50b8c73 | |||
| 66100855f6 | |||
| 3f23897327 | |||
| ed10f28879 | |||
| 45480966c1 |
@@ -12,3 +12,8 @@ ORCH_GITEA_WEBHOOK_SECRET=
|
||||
ORCH_CLAUDE_BIN=/usr/bin/claude
|
||||
ORCH_REPOS_DIR=/home/slin/repos
|
||||
ORCH_DB_PATH=/app/data/orchestrator.db
|
||||
# ORCH-042: live-tracker mode. edit (DEFAULT) -> the task card is edited in place
|
||||
# (editMessageText). bump -> on every update the old card is deleted and a fresh
|
||||
# one is sent silently to the BOTTOM of the chat (deleteMessage + sendMessage +
|
||||
# repoint). One card per task in both modes. Any value other than "bump" -> edit.
|
||||
ORCH_TRACKER_MODE=edit
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Режим `bump` live-трекера Telegram** (ORCH-042): новый `ORCH_TRACKER_MODE` (`Settings.tracker_mode`, дефолт `edit`) выбирает поведение карточки задачи. `edit` (как было) — карточка редактируется на месте (`editMessageText`). `bump` — на каждом обновлении старое сообщение удаляется и карточка отправляется заново вниз чата (best-effort `delete_telegram(старый_id)` → `send_telegram(text, disable_notification=True)` → `set_tracker_message_id(new_id)`), чтобы актуальный статус всегда был последним в чате при активной переписке. Инвариант «одна карточка на задачу» сохранён в обоих режимах: за один вызов `update_task_tracker` шлётся ≤1 нового сообщения; `set_tracker_message_id` вызывается ТОЛЬКО при успешном send (транзиентный `None` не затирает указатель); результат delete НЕ блокирует отправку новой карточки (delete-fail у сообщения >48ч → всё равно шлём новое). Резолюция режима в `notifications` (case-insensitive, trim): всё, что ≠ `"bump"` (включая пустое/мусор) → `edit` → нулевая регрессия и оркестратор не падает на любом значении флага. Новый low-level helper `delete_telegram(message_id) -> bool` (контракт «never raises», маркеры `_DELETE_GONE_MARKERS`): `ok:true` или «уже нет / нельзя удалить» → `True`; неизвестный `ok:false`/5xx/исключение → `False`; нет кредов → `False` без HTTP. Сигнатуры `send_telegram`/`edit_telegram`/`update_task_tracker` и схема БД (`tasks.tracker_message_id`) не менялись. ADR `docs/work-items/ORCH-042/06-adr/ADR-001-tracker-bump-mode.md`. Тесты: `tests/test_tracker_bump.py`, `tests/test_config.py`.
|
||||
- **Дословный текст findings reviewer/tester встраивается в `task_desc` заворота** (ORCH-046): при откате на `development` строка `task_desc` (попадает в `.task-dev.md` developer-агента) теперь несёт суть претензий, а не только ссылку на файл — устраняет «испорченный телефон», из-за которого агент шёл «читать файл», терял ключевые P0/P1 / причину FAIL и заворачивался снова, выжигая `MAX_DEVELOPER_RETRIES` и токены. Новый defensive-модуль `src/review_parse.py` (контракт «never raise», как `src/frontmatter.py`): `extract_review_findings(path)` — дословные пункты P0/P1 из секции `## Findings` файла `12-review.md`; `extract_test_failures(path)` — релевантный фрагмент тела `13-test-report.md` (приоритет `## Вывод pytest` → FAIL-строки `## Результаты` → `## Итог`). Обе функции усекают результат до `MAX_FINDINGS_CHARS`/`MAX_FAILURES_CHARS` (≈2000) с маркером `…(truncated)`. Две rollback-ветки `src/stage_engine.py` (reviewer REQUEST_CHANGES, tester `check_tests_passed` FAIL) встраивают извлечённый текст и **сохраняют ссылку** на полный файл («Полный контекст»); при пустом/битом артефакте — graceful-фоллбэк на прежнюю ссылку-строку (никаких исключений в `advance_stage`). Tester-ветка дополнительно всегда включает `reason` гейта. Последовательность отката, `_developer_retry_count`, поля `AdvanceResult` и реестр `QG_CHECKS` не менялись. ADR `docs/work-items/ORCH-046/06-adr/ADR-001-embed-findings-in-task-desc.md`. Тесты: `tests/test_review_parse.py`, `tests/test_stage_engine.py::TestRollbackTaskDescEmbedding`.
|
||||
- **Поллинг с ретраем в quality-gate `check_ci_green`** (ORCH-045): гейт CI превращён из single-shot в polling, чтобы устранить race condition — раньше один опрос combined commit-status сразу после пуша developer-а ловил транзиентный `pending` (типично 1-3с, реальный кейс ORCH-017: опрос 17:58:54 → pending, CI дозеленел 17:58:55) и задача застревала насмерть без повторного опроса. Теперь: `success` → пропуск сразу; `failure`/`error` → провал сразу (терминально, ретрай бессмыслен); `pending`/unknown → `time.sleep` и повторный опрос до `ci_poll_max_attempts` раз; истечение попыток → явный `(False, "CI still pending after <T>s")` (тупик больше не молчаливый); 404 → как раньше; транзиентная `httpx.HTTPError` на попытке логируется и ретраится в рамках бюджета. Параметры — новые настройки `ORCH_CI_POLL_MAX_ATTEMPTS` (12) и `ORCH_CI_POLL_INTERVAL_S` (10) в `src/config.py` (~2 мин ожидания pending). Сигнатура `check_ci_green(repo, branch)` и реестр `QG_CHECKS` не менялись; `check_tests_passed` не затронут. ADR `docs/architecture/adr/adr-0004-ci-poll-retry.md`. Тесты: `tests/test_qg.py::TestCheckCIGreen`.
|
||||
- **Прямые ссылки на BRD и Plane-таску в Telegram-уведомлении об апруве** (ORCH-017): пингующее сообщение `notify_approve_requested` теперь встраивает две HTML-`<a>`-ссылки — на `docs/work-items/<WI>/01-brd.md` (Gitea branch-view: `gitea_public_url`→`gitea_url`) и на issue в Plane (`{web_base}/{workspace}/projects/{project_id}/issues/{plane_issue_id}/`). Новая настройка `ORCH_PLANE_WEB_URL` (внешний браузерный web-URL Plane; фолбэк на `plane_api_url`). **Loopback-guard:** если итоговый Plane web-base указывает на localhost/127.0.0.1/0.0.0.0/::1 или пуст — Plane-ссылка опускается (не выпускаем битый localhost-URL). Graceful degradation: каждая ссылка строится независимо и опускается при нехватке данных, сообщение и призыв «Переведите задачу в статус Approved …» сохраняются всегда; ровно одно пингующее сообщение, разделяемая `send_telegram` не тронута. Динамические подписи экранируются `html.escape`, `parse_mode=HTML` сохранён. ADR `docs/work-items/ORCH-017/06-adr/ADR-001-telegram-approve-links.md`. Тесты: `test_notify_approve_links.py`, `test_analysis_approve_flow_links.py`.
|
||||
@@ -19,10 +20,12 @@
|
||||
- **Реестр проектов** (ORCH-6): `src/projects.py`, фильтрация вебхуков по проекту.
|
||||
|
||||
### Changed
|
||||
- **Русификация и косметика карточки live-трекера Telegram** (ORCH-042, оба режима): метка `Подтверждение BRD` вместо «Ревью БРД» (`_BRD_LABEL`); после прохождения approve-gate строка подтверждения BRD начинается с ✅ вместо ⏸️ (ветка ожидания человека сохраняет ⏸️/⏳); русские display-labels стадий в `_TRACKER_STAGES` (`Анализ / Архитектура / Разработка / Код ревью / Тестирование / Внедрение`) — применяются и в «✅ …», и в «🔄 … идёт»; финальная строка готовой задачи `📦 Внедрено` вместо `deployed` (`_done_link`). Меняются только отображаемые строки — ключи стадий и имена агентов не трогаются. Существующие ассерты `tests/test_telegram_tracker.py` обновлены под русские метки.
|
||||
- **Status-коммент агентов теперь HTML и единообразен** (ORCH-016): `src/usage.usage_comment(...)` помечен deprecated и стал тонкой обёрткой над `build_status_comment`; `src/usage.artifact_links(...)` теперь возвращает `<li><a>…</a></li>` HTML-фрагменты (раньше — markdown `[label](url)`); `stage_engine._build_analyst_ready_comment(...)` — тонкая обёртка, аналитик идёт через ту же ветку `build_status_comment(agent="analyst", ...)`. Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` НЕ изменялись.
|
||||
- Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`).
|
||||
|
||||
### Fixed
|
||||
- **Контейнер и агенты бегут под uid хоста (1000:1000), не root** (ORCH-040): оба сервиса в `docker-compose.yml` (`orchestrator`, `orchestrator-staging`) получили `user: "1000:1000"` (slin) — устраняет корень проблемы, при которой Claude-CLI агенты, запускаемые через `subprocess.Popen` внутри root-контейнера, создавали все артефакты конвейера (git worktree `/repos/_wt/...`, коммиты в `docs/work-items/...`) с владельцем `root:root` на хосте, из-за чего `git pull`/`git reset` под slin падали с `insufficient permission for adding an object` и каждый деплой требовал ручного `chown`. Теперь файлы сразу `slin:slin`. Доступ к docker.sock сохранён через `group_add: ["999"]` (МИНА 1 — НЕ удалена). SSH-маунт приведён к единому HOME агента: target `/root/.ssh` → `/home/slin/.ssh` (`/home/slin/.orchestrator-ssh:/home/slin/.ssh:ro`), синхронно с `HOME=/home/slin`, который launcher форсит в env Popen и git_env — устранён скрытый рассинхрон SSH-маунта с форсимым HOME. `src/agents/launcher.py` и `Dockerfile` НЕ менялись (numeric uid работает без записи в `/etc/passwd`; `safe.directory '*'` уже покрывает git над bind-mount). Требует host-prerequisites Owner (P-1…P-4, вне кода): блокер P-1 — `chown -R 1000:1000 /home/slin/.claude` для доступа uid 1000 к claude creds (иначе preflight заворачивает конвейер); прод-рестарт self — только в окно тишины (общий инстанс с enduro-trails), страховка — staging-гейт (adr-0003). ADR `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`; INFRA.md обновлён (рантайм-uid, volumes/SSH target, host-prerequisites). Тесты: `tests/test_orch040_compose.py`.
|
||||
- **Staging-чек B6 читает реестр из окружения работающего staging-инстанса** (ORCH-048): блок B6 «Registry: sandbox present, prod ET/ORCH absent» в `scripts/staging_check.py` давал **ложный FAIL** (`prod-ET=YES(BAD!)`, `prod-ORCH=YES(BAD!)`) при фактически исправной изоляции — единственный чек suite, который не ходил к инстансу по HTTP, а импортировал `src.projects` локально через host-path хак `sys.path.insert(0, "/repos/orchestrator")` + `importlib.reload`, строя реестр из `ORCH_PROJECTS_JSON` **process-env запускающего процесса**. При фактическом запуске деплоером с хоста переменная не задана → дефолт `_DEFAULT_PROJECTS` (ET+ORCH) → ложный FAIL → лишний откат `deploy-staging → development`. Решение (вариант «в», ADR-001): host-path хак удалён; suite канонически запускается ВНУТРИ контейнера `orchestrator-staging` через `docker exec … python3 /repos/orchestrator/scripts/staging_check.py` (`scripts/` доступен только через bind-mount, `import src.projects` резолвится через `PYTHONPATH=/app` из кода контейнера, env — `.env.staging`) → B6 читает реестр именно работающего инстанса, без HTTP-bootstrap и «курицы-яйца». Логика вердикта вынесена в чистую `_evaluate_b6(known) -> (passed, detail)` (инвариант `passed ⟺ SANDBOX ∈ known ∧ PROD_ET ∉ known ∧ PROD_ORCH ∉ known`, формат detail сохранён) + `_known_project_ids_from_registry()` / `_run_b6()` с детерминированным FAIL при недоступности источника (не ложный PASS, не необработанное исключение). Синхронно обновлены `.openclaw/agents/deployer.md` (команда стадии через `docker exec`) и `docs/operations/STAGING_CHECK.md`. `src/projects.py`, `.env*` и прочие чеки A/B4/B5/C не тронуты; реестр `QG_CHECKS` и `check_staging_status` (ADR-0003) не менялись. ADR `docs/work-items/ORCH-048/06-adr/ADR-001-b6-registry-via-in-container-run.md`. Тесты: `tests/test_staging_check_b6.py`.
|
||||
- **Testing-гейт `check_tests_passed` читает `result:` наравне с `verdict:`/`status:`** (ORCH-047): парсер `_parse_tests_verdict` (`src/qg/checks.py`) теперь принимает три равноправных машиночитаемых поля frontmatter `13-test-report.md` — `result:` (канон промпта тестера `.openclaw/agents/tester.md`, `result: PASS|FAIL`), плюс легаси `verdict:` и `status:` (enduro-trails ET-001..ET-014); достаточно любого одного непустого. Устраняет рассинхрон контракта: тестер честно эмитил `result: PASS` без `verdict:`/`status:`, парсер попадал в ветку «нет машинного вердикта» → откат `testing → development` в петлю до исчерпания `MAX_DEVELOPER_RETRIES` (наблюдалось на ORCH-17; ORCH-016 прошёл лишь из-за избыточного дублирования полей). Семантика приоритетов сохранена и распространена на все три поля через объединённую строку: negative-токен в любом поле авторитетен (перебивает positive), наборы токенов заморожены (обратная совместимость). Сигнатура гейта, имя и реестр `QG_CHECKS` не менялись. ADR `docs/work-items/ORCH-047/06-adr/ADR-001-result-field-in-tests-gate.md`. Тесты: `tests/test_qg.py::TestCheckTestsPassed`.
|
||||
- БАГ-8: провал deploy/deploy-staging → корректный откат на `development`.
|
||||
|
||||
@@ -3,6 +3,11 @@ services:
|
||||
build: .
|
||||
container_name: orchestrator
|
||||
restart: unless-stopped
|
||||
# ORCH-040: бежим под uid:gid хоста (slin=1000:1000), а не root, чтобы
|
||||
# артефакты конвейера (worktree + docs) создавались как slin:slin и git на
|
||||
# хосте работал без ручного chown. Доступ к docker.sock сохранён через
|
||||
# group_add: ["999"] (МИНА 1 — НЕ удалять). См. ADR-001 ORCH-040.
|
||||
user: "1000:1000"
|
||||
# init: true injects docker-init (tini) as PID 1 so reparented grandchild
|
||||
# processes from the claude/node subprocess tree are reaped (no zombies, B-2).
|
||||
init: true
|
||||
@@ -15,7 +20,8 @@ services:
|
||||
- /usr/bin/node:/usr/bin/node:ro
|
||||
- /home/slin/.claude:/home/slin/.claude
|
||||
- /home/slin/.claude.json:/home/slin/.claude.json:ro
|
||||
- /home/slin/.orchestrator-ssh:/root/.ssh:ro
|
||||
# ORCH-040: target согласован с HOME=/home/slin (launcher), не /root/.ssh.
|
||||
- /home/slin/.orchestrator-ssh:/home/slin/.ssh:ro
|
||||
env_file: .env
|
||||
environment:
|
||||
- ORCH_REPOS_DIR=/repos
|
||||
@@ -35,6 +41,8 @@ services:
|
||||
build: .
|
||||
container_name: orchestrator-staging
|
||||
restart: unless-stopped
|
||||
# ORCH-040: тот же uid хоста, что и у prod (см. комментарий выше / ADR-001).
|
||||
user: "1000:1000"
|
||||
init: true
|
||||
network_mode: host
|
||||
command: ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8501"]
|
||||
@@ -46,7 +54,8 @@ services:
|
||||
- /usr/bin/node:/usr/bin/node:ro
|
||||
- /home/slin/.claude:/home/slin/.claude
|
||||
- /home/slin/.claude.json:/home/slin/.claude.json:ro
|
||||
- /home/slin/.orchestrator-ssh:/root/.ssh:ro
|
||||
# ORCH-040: target согласован с HOME=/home/slin (launcher), не /root/.ssh.
|
||||
- /home/slin/.orchestrator-ssh:/home/slin/.ssh:ro
|
||||
env_file: .env.staging
|
||||
environment:
|
||||
- ORCH_REPOS_DIR=/repos
|
||||
|
||||
@@ -9,6 +9,7 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
|
||||
| adr-0002 | Очередь задач вместо in-process потоков | accepted | 2026-06-03 | ORCH-1 |
|
||||
| adr-0003 | Условный staging-гейт перед прод-деплоем | accepted | 2026-06-05 | ORCH-35 |
|
||||
| adr-0004 | Поллинг с ретраем в check_ci_green (фикс CI-race) | accepted | 2026-06-05 | ORCH-045 |
|
||||
| adr-0005 | Контейнеры бегут под uid:gid хоста (1000:1000) | accepted | 2026-06-06 | ORCH-040 |
|
||||
|
||||
## Формат
|
||||
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.
|
||||
|
||||
42
docs/architecture/adr/adr-0005-container-runs-as-host-uid.md
Normal file
42
docs/architecture/adr/adr-0005-container-runs-as-host-uid.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# adr-0005: Контейнеры оркестратора бегут под uid:gid хоста (1000:1000)
|
||||
|
||||
- **Статус:** accepted
|
||||
- **Дата:** 2026-06-06
|
||||
- **Задача:** ORCH-040
|
||||
|
||||
## Контекст
|
||||
Оба контейнера (`orchestrator`, `orchestrator-staging`) запускались под `uid=0 (root)` и
|
||||
монтировали хостовый `/home/slin/repos` → `/repos` (rw). Claude-CLI агенты исполняются
|
||||
`subprocess.Popen` внутри контейнера под тем же root, поэтому все артефакты конвейера
|
||||
(git worktree, коммиты в `docs/`) появлялись на хосте как `root:root`. Деплой прода под
|
||||
`slin` (uid 1000) ломался на правах git до ручного `chown`. Это сквозное свойство рантайма:
|
||||
касается агентов **всех** проектов, а не отдельной фичи.
|
||||
|
||||
## Решение
|
||||
Оба сервиса в `docker-compose.yml` запускаются под `user: "1000:1000"` (uid:gid хоста `slin`).
|
||||
- `group_add: ["999"]` сохраняется — доступ к docker.sock идёт через gid 999, не через root.
|
||||
- target SSH-маунта приведён к `/home/slin/.ssh` (был `/root/.ssh`), синхронно с
|
||||
`HOME=/home/slin`, который форсит launcher → единый HOME по осям uid/claude/ssh.
|
||||
- Образ и launcher не меняются: numeric uid не требует записи в `/etc/passwd`,
|
||||
`git config --system safe.directory '*'` уже есть.
|
||||
|
||||
Обязательные host-prerequisites (Owner, вне кода): доступ uid 1000 к
|
||||
`/home/slin/.claude/.credentials.json` (блокер), ssh-ключи в новом HOME, рестарт prod
|
||||
только в окно тишины. Детали и команды — work-item ADR-001 и `docs/operations/INFRA.md`.
|
||||
|
||||
## Альтернативы
|
||||
- **drop-privileges только для subprocess агента** (`gosu`/`setuid`) — контейнер остаётся
|
||||
root; новый код в горячем пути launcher, два uid в одном контейнере; отклонён.
|
||||
- **chown-хук после каждой стадии** — лечит симптом, требует root внутри контейнера
|
||||
(несовместимо), хрупкий пост-шаг; отклонён (fallback на крайний случай).
|
||||
|
||||
## Последствия
|
||||
- Артефакты создаются под `slin:slin`; деплой прода не требует ручного `chown`.
|
||||
- HOME консистентен (uid = claude = ssh = `/home/slin`); устранён рассинхрон SSH-маунта.
|
||||
- Появляется явная привязка рантайма к uid 1000 хоста (задокументирована в INFRA.md).
|
||||
- Прод-рестарт self = групповой риск (общий инстанс с enduro-trails) → строго окно тишины;
|
||||
страховка — staging-гейт (adr-0003).
|
||||
|
||||
## Связи
|
||||
adr-0003 (staging-гейт — обязательная проверка перед прод-рестартом self),
|
||||
adr-0001 (`is_self_hosting_repo`), work-item `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md`.
|
||||
@@ -107,6 +107,27 @@ claude.exe --print --system-prompt --allowedTools Read,Write,Edit,Bash
|
||||
2. Если < MAX_DEV_RETRIES (3) — откатывает в development, перезапускает developer
|
||||
3. Если >= MAX_DEV_RETRIES — эскалация (логирование + уведомление)
|
||||
|
||||
### 7. Live Telegram tracker (`src/notifications.py`)
|
||||
|
||||
Вместо ~15 отдельных сообщений на задачу оркестратор держит **ОДНУ** live-карточку на задачу (`update_task_tracker`), которая обновляется на каждом переходе стадии. Текст рендерится статически из БД (`render_task_tracker`: стадии, токены, стоимость, BRD-подтверждение, итоги). Карточка всегда тихая (`disable_notification=True`); отдельные пинги шлют только `notify_approve_requested` / `notify_error`. `message_id` хранится в `tasks.tracker_message_id`; helpers `get_tracker_message_id` / `set_tracker_message_id`. Контракт всего компонента — **never raises**.
|
||||
|
||||
**Режимы (ORCH-042, `ORCH_TRACKER_MODE` → `Settings.tracker_mode`).** Резолвится в `update_task_tracker` (case-insensitive, trim); всё, что ≠ `"bump"` (включая пустое/мусор/None), трактуется как `edit` → нулевая регрессия и безопасный фолбэк. Инвариант «одна карточка на задачу» сохраняется в обоих режимах.
|
||||
|
||||
| Режим | Поведение при обновлении |
|
||||
|-------|--------------------------|
|
||||
| `edit` (дефолт) | первый вызов → `send_telegram` (тихо) + сохранение `message_id`; далее → `edit_telegram` на сохранённый id. Новое сообщение шлётся ТОЛЬКО при `EDIT_GONE` (удалено/старше 48ч/невалидный id). `EDIT_NOT_MODIFIED` / `EDIT_FAILED` → нового сообщения нет (анти-дубль). |
|
||||
| `bump` | карточка пересоздаётся внизу чата: best-effort `delete_telegram(старый_id)` → `send_telegram(text, disable_notification=True)` → `set_tracker_message_id(new_id)` **только** при успешном send (`new_mid is not None`). За один вызов — не более одного нового сообщения. |
|
||||
|
||||
**`delete_telegram(message_id) -> bool`** (low-level, never raises). Семантика возврата — «исчезло ли старое сообщение»:
|
||||
- `ok:true` → `True`;
|
||||
- `ok:false` с маркерами `_DELETE_GONE_MARKERS` (`message to delete not found`, `message can't be deleted`, `message_id_invalid`) → `True` (старше 48ч / уже удалено — не транзиент);
|
||||
- прочий `ok:false` / 5xx / исключение (сеть/таймаут) → `False` + `logger.warning`;
|
||||
- нет токена/chat_id → `False`, HTTP не выполняется.
|
||||
|
||||
Результат `delete_telegram` **не** блокирует отправку новой карточки (BR-6: delete-fail у сообщения >48ч → всё равно шлём новое); `False` означает лишь «старое, возможно, ещё живо» — будет вычищено повторной попыткой на следующем переходе. При транзиентном сбое send (`None`) указатель `tracker_message_id` **не** затирается (анти-затирание, симметрично edit-fallback).
|
||||
|
||||
**Текст карточки (оба режима, ORCH-042):** метка `Подтверждение BRD` (была «Ревью БРД»); после прохождения approve-gate строка BRD начинается с ✅ (ветка ожидания сохраняет ⏸️/⏳); русские display-labels стадий (`Анализ / Архитектура / Разработка / Код ревью / Тестирование / Внедрение`); финальная строка `📦 Внедрено` (было `deployed`). Меняются только отображаемые строки — ключи стадий и имена агентов (завязаны на `_STAGE_ACTIVE_AGENT`, `last_done`, БД) не трогаются.
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
|
||||
@@ -30,12 +30,33 @@
|
||||
|
||||
Оба: `network_mode: host`, `init: true` (tini как PID 1 — reaping зомби, B-2), `restart: unless-stopped`.
|
||||
|
||||
### Рантайм-uid (ORCH-040)
|
||||
Оба сервиса бегут под `user: "1000:1000"` (slin), **не** root. Артефакты конвейера
|
||||
(git worktree `/repos/_wt/...`, коммиты в `docs/work-items/...`) создаются как
|
||||
`slin:slin`, поэтому `git pull` / `git reset` на хосте под slin работают без ручного
|
||||
`chown`. Доступ к docker.sock сохранён через `group_add: ["999"]` (gid docker, **не**
|
||||
через root — НЕ удалять). При переносе на другой хост uid пересматривается. См.
|
||||
ADR `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`.
|
||||
|
||||
**Host-prerequisites (обязательная процедура Owner, в git не коммитятся):**
|
||||
- **P-1 (блокер):** uid 1000 читает claude creds — `chown -R 1000:1000 /home/slin/.claude`;
|
||||
проверка `sudo -u '#1000' test -r /home/slin/.claude/.credentials.json`. Без этого
|
||||
preflight (ORCH-044) заворачивает весь конвейер.
|
||||
- **P-2:** ssh-ключи в `/home/slin/.orchestrator-ssh` читаемы uid 1000 (маунт ведёт в `/home/slin/.ssh`).
|
||||
- **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` файлов из истории (вне объёма кода).
|
||||
|
||||
### Тома (volumes)
|
||||
- `./data` → `/app/data` (БД; у staging — `./data/staging`)
|
||||
- `/home/slin/repos` → `/repos` (рабочие репозитории проектов)
|
||||
- `/var/run/docker.sock` (для docker-операций деплоя)
|
||||
- claude-code, node, `~/.claude*` (CLI агентов, ro)
|
||||
- `~/.orchestrator-ssh` → `/root/.ssh` (ro, деплой по ssh)
|
||||
- `~/.orchestrator-ssh` → `/home/slin/.ssh` (ro, деплой по ssh; target в HOME агента,
|
||||
согласован с `HOME=/home/slin` из launcher — ORCH-040, ранее `/root/.ssh`)
|
||||
|
||||
## Переменные окружения (карта; значения — в `.env`)
|
||||
|
||||
|
||||
7
docs/work-items/ORCH-040/00-business-request.md
Normal file
7
docs/work-items/ORCH-040/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: Агенты пишут файлы под root в смонтированный хост-репо: ломает git/ребилд
|
||||
|
||||
Work Item ID: ORCH-040
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
106
docs/work-items/ORCH-040/01-brd.md
Normal file
106
docs/work-items/ORCH-040/01-brd.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 01 — BRD: Агенты пишут файлы под root в смонтированный хост-репо
|
||||
|
||||
Work Item: **ORCH-040**
|
||||
Тип: инфра-фикс (runtime / docker-compose)
|
||||
Исполнение: через Dev напрямую (по решению Owner)
|
||||
|
||||
## 1. Бизнес-контекст и проблема
|
||||
|
||||
Контейнер `orchestrator` (prod, 8500) работает под `uid=0 (root)`. Он монтирует
|
||||
хостовый каталог `/home/slin/repos` → `/repos` (rw). Claude-CLI агенты запускаются
|
||||
через `subprocess.Popen` **внутри контейнера**, то есть тоже под root. Они пишут:
|
||||
|
||||
- в git worktree задач — `/repos/_wt/<repo>/<branch>/...`;
|
||||
- в прод-клон — `/repos/<repo>/docs/work-items/...` (через коммит/пуш из worktree).
|
||||
|
||||
В результате на **хосте** файлы создаются с владельцем `root:root`.
|
||||
|
||||
### Симптом
|
||||
При ребилде/деплое прода `git pull` / `git reset` под пользователем `slin` падает:
|
||||
|
||||
```
|
||||
error: insufficient permission for adding an object to repository database .git/objects
|
||||
Permission denied (на docs/work-items/ORCH-016, владелец root:root)
|
||||
```
|
||||
|
||||
Каждый будущий деплой будет ломаться, пока вручную не выполнить `chown`.
|
||||
|
||||
### Диагноз (живая разведка 05–06.06)
|
||||
- `docker exec orchestrator id` → `uid=0(root) gid=0(root) groups=0,999`.
|
||||
- Хост `slin` = `uid=1000 gid=1000`, группы: `sudo`, `docker(999)`.
|
||||
- `/home/slin/repos` → `/repos` (rw); на хосте `/repos` уже `1000:1000 rwxrwxr-x`.
|
||||
- `docs/work-items/*` на хосте — `root:root` (наследие прошлых прогонов).
|
||||
|
||||
## 2. Цель
|
||||
|
||||
Агенты конвейера **не должны** создавать `root`-файлы в хостовом репозитории.
|
||||
После любого прогона конвейера `git pull/status/reset` под `slin` на хосте
|
||||
работает **без ручного chown**.
|
||||
|
||||
## 3. Объём (scope)
|
||||
|
||||
В объёме:
|
||||
- Изменение runtime-режима контейнера так, чтобы артефакты создавались под
|
||||
`uid:gid` хоста (`1000:1000`).
|
||||
- Сохранение работоспособности: claude-auth (preflight), git/ssh, docker.sock
|
||||
(деплой), запуск конвейера.
|
||||
- Обновление документации (INFRA.md, CHANGELOG, ADR с обоснованием варианта).
|
||||
- Проверка на staging (8501) ДО прода.
|
||||
|
||||
Вне объёма:
|
||||
- Массовое исправление прав уже существующих `root:root` файлов в истории
|
||||
(разовый `chown` на хосте делает Owner; в задаче — только описать команду).
|
||||
- Изменение логики конвейера, QG, схемы БД.
|
||||
- Смена модели/effort агентов, прочие фичи.
|
||||
|
||||
## 4. Заинтересованные стороны
|
||||
- Owner (Слава) — заказчик, владелец хоста mva154.
|
||||
- Стрим — разведка/контекст.
|
||||
- Проект enduro-trails — co-tenant того же прод-инстанса (групповой риск).
|
||||
|
||||
## 5. Ограничения и риски (off-limits)
|
||||
|
||||
Self-hosting: прод-инстанс `orchestrator` ОДИН на все прод-проекты, общая БД и
|
||||
очередь. **Нельзя ломать**: запуск конвейера, доступ к Plane/Gitea/SSH из агентов,
|
||||
docker.sock. Любой рестарт контейнера под новым uid — **только в окно тишины**
|
||||
(нет активных задач). Тестировать на staging ПЕРЕД продом.
|
||||
|
||||
### Известные мины (подтверждены разведкой)
|
||||
- **МИНА 1 — docker.sock**: `/var/run/docker.sock` = `srw-rw---- root:999`.
|
||||
Доступ идёт через gid 999, не через root. При переходе на непривилегированный
|
||||
uid обязателен supplementary group `999`. *В текущем `docker-compose.yml` уже
|
||||
есть `group_add: ["999"]` для обоих сервисов — учесть, не сломать.*
|
||||
- **МИНА 2 — claude creds (БЛОКЕР)**: `/home/slin/.claude/.credentials.json` =
|
||||
`root:root 0600`. Сейчас читает контейнер-root. Под `uid=1000` без доступа →
|
||||
`claude-auth` ломается → весь конвейер умирает (preflight ORCH-044 заворачивает).
|
||||
Проверить ПЕРВЫМ.
|
||||
- **МИНА 3 — claude бинарь**: реальный бинарь `/opt/claude-code/bin/claude.exe`
|
||||
(root:root, `+x` для всех — ok). `ORCH_CLAUDE_BIN=/usr/bin/claude` в env не
|
||||
существует; launcher использует hardcode `CLAUDE_BIN=/opt/claude-code/bin/claude.exe`.
|
||||
Под uid 1000 исполним, но проверить запуск.
|
||||
- **SSH-маунт**: `/home/slin/.orchestrator-ssh` → `/root/.ssh:ro`. При смене uid
|
||||
HOME/домашний каталог меняется — путь к ключам нужно поправить (деплой по ssh).
|
||||
- **HOME**: launcher форсит `HOME=/home/slin` (две точки: env Popen и git_env).
|
||||
Креды читаются из `/home/slin/.claude`. Учесть при смене uid.
|
||||
|
||||
## 6. Бизнес-ценность
|
||||
Устранение постоянного ручного `chown` после каждого деплоя; деплой прода
|
||||
перестаёт ломаться на правах; снимается источник простоя конвейера всех проектов.
|
||||
|
||||
## 7. Допущения
|
||||
- Хост-каталоги `/app/data` и `/repos` уже `1000:1000` (запись под uid 1000 пройдёт).
|
||||
- Dockerfile уже содержит `git config --system --add safe.directory '*'`.
|
||||
- Окно тишины для рестарта контейнера согласуется с Owner.
|
||||
|
||||
## 8. Host-prerequisites (предусловия на стороне Owner)
|
||||
Часть фикса невозможно закрыть только кодом — есть действия на хосте mva154,
|
||||
которые выполняет Owner (в гит не коммитятся, фиксируются в ADR/INFRA). Это
|
||||
обязательные предусловия Варианта 1; без них переход на uid 1000 ломает конвейер:
|
||||
- **P-1 (блокер, МИНА 2):** обеспечить чтение `/home/slin/.claude/.credentials.json`
|
||||
под uid 1000 (рекомендация — `chown -R 1000:1000 /home/slin/.claude`). Способ
|
||||
выбирает ADR; анализ фиксирует факт предусловия.
|
||||
- **P-2:** ssh-ключи (`/home/slin/.orchestrator-ssh`) читаемы uid 1000.
|
||||
- **P-3:** подтверждение `slin = uid 1000 gid 1000` (подтверждено разведкой).
|
||||
- **P-4:** рестарт прод-self только в окно тишины (`GET /status` без активных задач).
|
||||
|
||||
Детализация и команды — в `02-trz.md` §10.
|
||||
112
docs/work-items/ORCH-040/02-trz.md
Normal file
112
docs/work-items/ORCH-040/02-trz.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# 02 — ТЗ: agent-файлы под uid хоста (не root)
|
||||
|
||||
Work Item: **ORCH-040**
|
||||
|
||||
## 1. Суть требования
|
||||
Артефакты конвейера (worktree + docs) должны создаваться на хосте под
|
||||
`uid:gid = 1000:1000` (slin), а не `root:root`. При этом сохраняется работа
|
||||
claude-auth, git, ssh-деплоя и docker.sock.
|
||||
|
||||
## 2. Задействованные модули и файлы
|
||||
|
||||
| Файл | Роль в задаче |
|
||||
|------|----------------|
|
||||
| `docker-compose.yml` | runtime-режим контейнера (prod `orchestrator` + `orchestrator-staging`). Основная точка изменения. |
|
||||
| `Dockerfile` | возможные правки под непривилегированный запуск (safe.directory уже есть; при необходимости — создание пользователя/прав). |
|
||||
| `src/agents/launcher.py` | `HOME=/home/slin` хардкод (env Popen ~стр.326 и git_env ~стр.513); путь `CLAUDE_BIN` (стр.187). Проверить совместимость при смене uid; править ТОЛЬКО при необходимости. |
|
||||
| `docs/operations/INFRA.md` | блок «Тома (volumes)» (SSH-маунт `/root/.ssh`), карта рантайма — обновить. |
|
||||
| `CHANGELOG.md` | запись об изменении. |
|
||||
| `docs/work-items/ORCH-040/06-adr/` | ADR с выбором варианта + обоснованием (создаёт архитектор). |
|
||||
|
||||
## 3. Варианты решения (вход для ADR — выбор и обоснование за архитектором)
|
||||
|
||||
> Анализ фиксирует варианты как требование «выбрать и обосновать в ADR».
|
||||
> Рекомендация разведки — Вариант 1.
|
||||
|
||||
1. **Вариант 1 (рекомендован): `user: "1000:1000"` в docker-compose.**
|
||||
Все файлы сразу `slin:slin`, git на хосте без chown. Обязательные довески:
|
||||
- сохранить/проверить `group_add: ["999"]` (docker.sock) — **уже присутствует**;
|
||||
- обеспечить доступ uid 1000 к claude creds (`/home/slin/.claude/.credentials.json`):
|
||||
`chown 1000:1000` на хосте ИЛИ права на чтение для 1000 (задокументировать);
|
||||
- поправить SSH-маунт: `/home/slin/.orchestrator-ssh` → домашний каталог uid 1000
|
||||
(`/home/slin/.ssh`), а не `/root/.ssh`; согласовать с `HOME` в launcher;
|
||||
- проверить запуск `claude.exe` + `git` + `ssh` под uid 1000.
|
||||
|
||||
2. **Вариант 2: subprocess агента под непривилегированным uid внутри контейнера**
|
||||
(`Popen preexec_fn setuid` / `gosu`). Точечно, но сложнее; контейнер остаётся root.
|
||||
|
||||
3. **Вариант 3 (fallback, костыль): chown-хук нормализации прав после стадии**
|
||||
(`chown -R 1000:1000` worktree/доки). Лечит симптом, не корень. Применять, только
|
||||
если В1 неустранимо рвёт creds/sock.
|
||||
|
||||
## 4. Требуемые изменения (при выбранном Варианте 1)
|
||||
|
||||
### 4.1 docker-compose.yml (оба сервиса: `orchestrator`, `orchestrator-staging`)
|
||||
- Добавить `user: "1000:1000"`.
|
||||
- Сохранить `group_add: ["999"]` (НЕ удалять).
|
||||
- Изменить SSH-маунт: target `/root/.ssh` → каталог `.ssh` пользователя 1000,
|
||||
синхронно с `HOME`, который форсит launcher (`/home/slin`). То есть привести к
|
||||
единому HOME: маунт `/home/slin/.orchestrator-ssh` → `/home/slin/.ssh:ro`.
|
||||
- Маунт `/home/slin/.claude` и `.claude.json` — оставить; проверить доступ uid 1000.
|
||||
|
||||
### 4.2 Доступ к claude creds
|
||||
- Обеспечить, что `/home/slin/.claude/.credentials.json` читается uid 1000
|
||||
(на хосте — операция Owner; в ТЗ зафиксировать команду и проверку).
|
||||
|
||||
### 4.3 src/agents/launcher.py
|
||||
- Проверить, что `HOME=/home/slin` остаётся валиден под uid 1000 (домашний каталог
|
||||
существует и доступен). Менять ТОЛЬКО при доказанной необходимости.
|
||||
- Не менять CLAUDE_BIN, если запуск под 1000 подтверждён.
|
||||
|
||||
### 4.4 Dockerfile
|
||||
- Менять при необходимости (например, гарантировать существование `/home/slin` и
|
||||
права). `git config --system --add safe.directory '*'` уже есть — оставить.
|
||||
|
||||
## 5. Изменения API
|
||||
Нет.
|
||||
|
||||
## 6. Изменения схемы БД
|
||||
Нет.
|
||||
|
||||
## 7. Новые QG checks
|
||||
Нет. Существующий staging-гейт (`check_staging_status`, ORCH-35) — обязательная
|
||||
страховка перед прод-деплоем self (без изменений).
|
||||
|
||||
## 8. Артефакты pipeline, которые должны быть созданы/обновлены
|
||||
- `06-adr/ADR-NNN-<slug>.md` — выбор варианта + обоснование (мины 1–3, SSH, HOME).
|
||||
- `docs/operations/INFRA.md` — обновить блок volumes (SSH target) и, при изменении
|
||||
режима, упоминание uid рантайма.
|
||||
- `CHANGELOG.md` — запись `fix:`/`refactor:` по Conventional Commits.
|
||||
- `12-review.md`, `13-test-report.md`, `15-staging-log.md` — по ходу конвейера.
|
||||
|
||||
## 9. Порядок безопасного внедрения (требование)
|
||||
1. Живая разведка прав creds/sock/ssh ДО кода.
|
||||
2. Применить и проверить на **staging (8501)** end-to-end.
|
||||
3. Прод-рестарт контейнера под новым uid — только в окно тишины (нет активных задач).
|
||||
4. Регресс на хосте: новые tracked-артефакты `1000:1000`, `git pull` под slin без ошибок.
|
||||
|
||||
## 10. Зависимости и host-prerequisites (действия на хосте, вне кода)
|
||||
|
||||
Эти пункты — предусловия для Варианта 1; их выполняет Owner на хосте mva154 (в гит
|
||||
не коммитятся, но фиксируются в ADR/INFRA как обязательная процедура). Без них
|
||||
переход контейнера на uid 1000 ломает конвейер (МИНА 2 — блокер).
|
||||
|
||||
| # | Предусловие | Команда / проверка | Зачем |
|
||||
|---|-------------|--------------------|-------|
|
||||
| P-1 | Доступ uid 1000 к claude creds | `chown -R 1000:1000 /home/slin/.claude` (вкл. `.credentials.json`); проверка `sudo -u '#1000' test -r /home/slin/.claude/.credentials.json` | МИНА 2: без доступа preflight ORCH-044 завернёт весь конвейер |
|
||||
| P-2 | SSH-ключи в HOME нового uid и читаемы | ключи в `/home/slin/.orchestrator-ssh` читаемы uid 1000; маунт ведёт в `/home/slin/.ssh` (см. §4.1) | деплой по ssh (DEPLOY_SSH_*) |
|
||||
| P-3 | Подтверждение uid:gid рантайма | `id slin` → `uid=1000 gid=1000`; `/repos` и `/app/data` уже `1000:1000` (подтверждено разведкой) | целевые файлы создаются под slin |
|
||||
| P-4 | Окно тишины для рестарта self | `GET /status` → нет активных задач перед рестартом прод-контейнера | self-hosting: общий инстанс с enduro-trails |
|
||||
|
||||
> **Открытый выбор для ADR (не решается анализом):** способ обеспечения P-1 —
|
||||
> `chown` creds (рекомендация разведки) vs. ослабление read-прав vs. отказ от
|
||||
> Варианта 1 в пользу Варианта 3 (chown-хук). Анализ фиксирует P-1 как
|
||||
> обязательное предусловие при любом из вариантов 1/2; для Варианта 3 — неактуально.
|
||||
|
||||
## 11. Подтверждённые факты текущего рантайма (anchor для Dev)
|
||||
Сверено с веткой `feature/ORCH-040-root-git` на 06.06:
|
||||
- `docker-compose.yml`: оба сервиса имеют `group_add: ["999"]` (МИНА 1 — НЕ удалять);
|
||||
SSH-маунт обоих = `/home/slin/.orchestrator-ssh:/root/.ssh:ro` (требует правки target);
|
||||
claude-маунты = `/home/slin/.claude` и `/home/slin/.claude.json:ro`.
|
||||
- `src/agents/launcher.py`: `HOME="/home/slin"` форсится в env Popen (стр. 326) и в
|
||||
git_env (стр. 513); `CLAUDE_BIN="/opt/claude-code/bin/claude.exe"` (стр. 187).
|
||||
62
docs/work-items/ORCH-040/03-acceptance-criteria.md
Normal file
62
docs/work-items/ORCH-040/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 03 — Критерии приёмки: ORCH-040
|
||||
|
||||
Work Item: **ORCH-040**
|
||||
|
||||
Каждый критерий имеет чёткое условие PASS/FAIL. Задача считается принятой, когда
|
||||
**все** критерии = PASS.
|
||||
|
||||
## AC-1 — Артефакты создаются под uid хоста (корневой критерий)
|
||||
- **PASS**: после прогона тестовой задачи конвейером end-to-end новые tracked-файлы
|
||||
в `/home/slin/repos/orchestrator/docs/work-items/*` и в worktree
|
||||
(`/repos/_wt/...`) имеют владельца `slin:slin` (1000:1000).
|
||||
`ls -ld /home/slin/repos/orchestrator/docs/work-items/*` → НЕ `root:root`.
|
||||
- **FAIL**: появляются новые `root:root` tracked-файлы.
|
||||
|
||||
## AC-2 — git под slin работает без ручного chown
|
||||
- **PASS**: на хосте под `slin` `git -C /home/slin/repos/orchestrator pull`,
|
||||
`git status`, `git reset` выполняются без `Permission denied` /
|
||||
`insufficient permission for adding an object`.
|
||||
- **FAIL**: любая из команд падает на правах.
|
||||
|
||||
## AC-3 — claude-агенты стартуют (preflight ok)
|
||||
- **PASS**: `claude-auth`/preflight проходит; агент конвейера запускается и
|
||||
завершается `exit_code=0` (не `Not logged in`, не отказ доступа к creds).
|
||||
- **FAIL**: агент падает на авторизации/чтении `/home/slin/.claude`.
|
||||
|
||||
## AC-4 — docker.sock доступен (деплой не сломан)
|
||||
- **PASS**: из контейнера под новым uid `docker ps` / docker-операции деплоя
|
||||
(ORCH-36 путь) работают — доступ через gid 999 сохранён (`group_add: ["999"]`).
|
||||
- **FAIL**: docker-операции отваливаются (`permission denied` на сокете).
|
||||
|
||||
## AC-5 — SSH-деплой работает
|
||||
- **PASS**: ssh-ключи читаются из домашнего каталога нового uid; деплой-хук по ssh
|
||||
(`DEPLOY_SSH_*`) выполняется.
|
||||
- **FAIL**: ssh не находит/не читает ключи (маунт указывает на чужой HOME).
|
||||
|
||||
## AC-6 — Конвейер не сломан (без регресса)
|
||||
- **PASS**: тестовая задача проходит стадии без падения запуска конвейера; доступ к
|
||||
Plane/Gitea из агентов сохранён; `pytest tests/ -q` зелёный.
|
||||
- **FAIL**: конвейер встаёт / тесты падают.
|
||||
|
||||
## AC-7 — Проверено на staging ДО прода
|
||||
- **PASS**: изменение прогнано на staging (8501), `15-staging-log.md` →
|
||||
`staging_status:` положительный; прод-рестарт выполнен в окно тишины.
|
||||
- **FAIL**: изменение применено сразу на прод без staging-прогона.
|
||||
|
||||
## AC-8 — Документация обновлена (golden source)
|
||||
- **PASS**: `docs/operations/INFRA.md` (блок volumes / SSH target / uid рантайма)
|
||||
и `CHANGELOG.md` обновлены; ADR с выбором варианта и обоснованием создан в
|
||||
`06-adr/`. Reviewer подтверждает.
|
||||
- **FAIL**: код изменён, документация/ADR не обновлены.
|
||||
|
||||
## AC-9 — Прод-контейнер не уронен вне окна тишины
|
||||
- **PASS**: рестарт self выполнен без активных задач; конвейер enduro-trails не
|
||||
пострадал.
|
||||
- **FAIL**: рестарт во время активных задач / падение прод-инстанса.
|
||||
|
||||
## AC-10 — Host-prerequisites зафиксированы и выполнены
|
||||
- **PASS**: предусловия P-1…P-4 (TRZ §10 / BRD §8) описаны в ADR/INFRA как
|
||||
обязательная процедура Owner; P-1 (доступ uid 1000 к claude creds) фактически
|
||||
обеспечен — подтверждается прохождением AC-3.
|
||||
- **FAIL**: фикс применён без обеспечения доступа к creds (P-1) → preflight/конвейер
|
||||
падает; либо предусловия нигде не задокументированы.
|
||||
81
docs/work-items/ORCH-040/04-test-plan.yaml
Normal file
81
docs/work-items/ORCH-040/04-test-plan.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
work_item: ORCH-040
|
||||
description: >
|
||||
Инфра-фикс: контейнер/агенты не плодят root-файлы в хостовом репо.
|
||||
Часть проверок автоматизируема через pytest (валидация compose-конфига),
|
||||
часть — обязательные ops/integration проверки на staging и хосте (manual),
|
||||
т.к. касаются прав файловой системы хоста и рантайма docker.
|
||||
|
||||
tests:
|
||||
# --- Автоматизируемые (pytest, парсинг docker-compose.yml) ---
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: >
|
||||
docker-compose.yml: оба сервиса (orchestrator, orchestrator-staging)
|
||||
имеют user: "1000:1000" (при выборе Варианта 1).
|
||||
module: tests/test_orch040_compose.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: >
|
||||
docker-compose.yml: оба сервиса сохраняют group_add со значением "999"
|
||||
(доступ к docker.sock не потерян — МИНА 1).
|
||||
module: tests/test_orch040_compose.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: >
|
||||
docker-compose.yml: SSH-маунт согласован с HOME агента — target каталога
|
||||
.ssh лежит под /home/slin (а не /root/.ssh), для обоих сервисов.
|
||||
module: tests/test_orch040_compose.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: >
|
||||
launcher: HOME, форсимый в окружении агента и git_env, указывает на каталог,
|
||||
совместимый с SSH/claude-маунтами (/home/slin) — нет рассинхрона HOME vs uid.
|
||||
module: tests/test_orch040_compose.py
|
||||
expected: PASS
|
||||
|
||||
# --- Регресс существующего поведения ---
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: >
|
||||
Весь существующий набор тестов зелёный (нет регресса логики конвейера/launcher).
|
||||
module: tests/ # pytest tests/ -q
|
||||
expected: PASS
|
||||
|
||||
# --- Integration / ops (staging 8501, затем хост) ---
|
||||
- id: TC-06
|
||||
type: integration
|
||||
description: >
|
||||
На staging (8501) прогнать тестовую задачу конвейером end-to-end; артефакты
|
||||
worktree и docs создаются под 1000:1000 (НЕ root:root). Проверка AC-1.
|
||||
module: scripts/staging_check.py # + ls -ld на хосте
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: integration
|
||||
description: >
|
||||
После staging-прогона на хосте под slin: git -C /home/slin/repos/orchestrator
|
||||
pull/status/reset без Permission denied. Проверка AC-2.
|
||||
module: manual/host-check
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: integration
|
||||
description: >
|
||||
claude preflight/auth проходит под новым uid: агент стартует и завершается
|
||||
exit_code=0 (creds /home/slin/.claude читаются). Проверка AC-3 (МИНА 2).
|
||||
module: manual/staging-agent-run
|
||||
expected: PASS
|
||||
|
||||
- id: TC-09
|
||||
type: integration
|
||||
description: >
|
||||
docker.sock доступен из контейнера под uid 1000 (docker ps работает) и
|
||||
ssh-деплой-хук выполняется. Проверка AC-4, AC-5 (МИНА 1 + SSH).
|
||||
module: manual/staging-deploy-path
|
||||
expected: PASS
|
||||
@@ -0,0 +1,109 @@
|
||||
# ADR-001: Контейнер и агенты бегут под uid:gid хоста (1000:1000), а не root
|
||||
|
||||
- **Статус:** Accepted
|
||||
- **Дата:** 2026-06-06
|
||||
- **Задача:** ORCH-040
|
||||
- **Связи:** глобальный [adr-0005](../../../architecture/adr/adr-0005-container-runs-as-host-uid.md), adr-0003 (staging-гейт — страховка перед прод-рестартом self), adr-0001 (`is_self_hosting_repo`).
|
||||
|
||||
## Контекст
|
||||
|
||||
Контейнер `orchestrator` (prod, 8500) работает под `uid=0 (root)` и монтирует хостовый
|
||||
`/home/slin/repos` → `/repos` (rw). Claude-CLI агенты запускаются через
|
||||
`subprocess.Popen` **внутри контейнера**, т.е. под тем же root. Все артефакты конвейера
|
||||
(git worktree `/repos/_wt/...`, коммиты в `docs/work-items/...`) появляются на **хосте**
|
||||
с владельцем `root:root`.
|
||||
|
||||
Следствие: при каждом деплое прода `git pull` / `git reset` под пользователем `slin`
|
||||
(uid 1000) падает с `insufficient permission for adding an object to repository database`
|
||||
/ `Permission denied`. Каждый деплой ломается, пока вручную не сделать `chown`.
|
||||
|
||||
Разведкой (05–06.06) подтверждено:
|
||||
- `slin = uid 1000 gid 1000`, в группах `sudo`, `docker(999)`; на хосте `/repos` и
|
||||
`/app/data` уже `1000:1000`.
|
||||
- launcher **уже** форсит `HOME=/home/slin` в двух местах: env `Popen` (`launcher.py:326`)
|
||||
и `git_env` (`launcher.py:513`). Креды читаются из `/home/slin/.claude`.
|
||||
- `docker-compose.yml`: оба сервиса имеют `group_add: ["999"]` (доступ к docker.sock —
|
||||
через gid 999, **не** через root); SSH-маунт обоих = `/home/slin/.orchestrator-ssh:/root/.ssh:ro`.
|
||||
- `CLAUDE_BIN=/opt/claude-code/bin/claude.exe` (`launcher.py:187`), `+x` для всех.
|
||||
- Dockerfile содержит `git config --system --add safe.directory '*'`.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
1. **Вариант 1 (выбран): `user: "1000:1000"` в docker-compose для обоих сервисов.**
|
||||
Контейнер целиком бежит под uid 1000. Все файлы сразу `slin:slin`, git на хосте без
|
||||
chown. Лечит корень проблемы одной декларативной строкой на сервис, без нового кода.
|
||||
|
||||
2. **Вариант 2: drop-privileges только для subprocess агента** (`gosu` / `preexec_fn setuid`).
|
||||
Контейнер остаётся root, агент бежит под 1000. Точечно, но: новый код в горячем пути
|
||||
launcher, два класса процессов с разными uid в одном контейнере (uvicorn root vs агент
|
||||
1000), сложнее отлаживать, выше риск регресса конвейера. Корень (root-владение из самого
|
||||
uvicorn-процесса при операциях с `/repos`) лечится не полностью.
|
||||
|
||||
3. **Вариант 3 (fallback): chown-хук нормализации прав после стадии**
|
||||
(`chown -R 1000:1000` worktree/docs). Лечит симптом, не причину; требует root внутри
|
||||
контейнера (т.е. несовместим с В1) и добавляет хрупкий пост-шаг в каждый переход стадии.
|
||||
|
||||
## Решение
|
||||
|
||||
Принимаем **Вариант 1**. Изменения (применяет Dev на стадии development):
|
||||
|
||||
1. **`docker-compose.yml`** — для **обоих** сервисов (`orchestrator`, `orchestrator-staging`):
|
||||
- добавить `user: "1000:1000"`;
|
||||
- **сохранить** `group_add: ["999"]` (МИНА 1 — НЕ удалять);
|
||||
- изменить target SSH-маунта `/root/.ssh` → `/home/slin/.ssh`, чтобы он совпал с
|
||||
`HOME=/home/slin`, который форсит launcher. Итог: `/home/slin/.orchestrator-ssh:/home/slin/.ssh:ro`;
|
||||
- claude-маунты (`/home/slin/.claude`, `/home/slin/.claude.json:ro`) — оставить как есть.
|
||||
|
||||
2. **`src/agents/launcher.py`** — НЕ менять. `HOME=/home/slin` и
|
||||
`CLAUDE_BIN=/opt/claude-code/bin/claude.exe` остаются валидными под uid 1000
|
||||
(`/home/slin` материализуется bind-маунтами; бинарь исполним для всех). Правка
|
||||
допустима ТОЛЬКО при доказанной поломке запуска под 1000.
|
||||
|
||||
3. **`Dockerfile`** — НЕ менять. Отдельный non-root user внутри образа не создаём:
|
||||
numeric `user: "1000:1000"` работает без записи в `/etc/passwd`; `safe.directory '*'`
|
||||
уже покрывает git над bind-маунтом. Правка допустима только если запуск под 1000
|
||||
выявит отсутствующий каталог/право.
|
||||
|
||||
### Host-prerequisites (вне кода, выполняет Owner — обязательная процедура)
|
||||
|
||||
Без них переход на uid 1000 ломает конвейер. Фиксируются здесь и в INFRA.md как
|
||||
обязательная процедура; в git не коммитятся.
|
||||
|
||||
| # | Предусловие | Команда / проверка | Зачем |
|
||||
|---|-------------|--------------------|-------|
|
||||
| P-1 (блокер) | uid 1000 читает claude creds | `chown -R 1000:1000 /home/slin/.claude`; проверка `sudo -u '#1000' test -r /home/slin/.claude/.credentials.json` | МИНА 2: иначе preflight (ORCH-044) завернёт весь конвейер |
|
||||
| P-2 | ssh-ключи читаемы uid 1000 и в новом HOME | ключи в `/home/slin/.orchestrator-ssh` читаемы 1000; маунт ведёт в `/home/slin/.ssh` | деплой по ssh (`DEPLOY_SSH_*`) |
|
||||
| P-3 | uid:gid рантайма подтверждён | `id slin` → `1000:1000`; `/repos`, `/app/data` уже `1000:1000` | целевые файлы под slin |
|
||||
| P-4 | рестарт self только в окно тишины | `GET /status` без активных задач перед рестартом prod | self-hosting: общий инстанс с enduro-trails |
|
||||
|
||||
**Выбор способа P-1:** `chown -R 1000:1000 /home/slin/.claude` (рекомендация разведки).
|
||||
Обоснование: креды и так принадлежат slin по смыслу; chown проще и надёжнее ослабления
|
||||
read-битов и не оставляет файл world-readable. Маунт `/home/slin/.claude` оставлен rw —
|
||||
claude CLI может обновлять токен; под uid 1000 после chown это работает.
|
||||
|
||||
## Порядок безопасного внедрения (обязателен)
|
||||
|
||||
1. Применить и проверить **на staging (8501)** end-to-end (артефакты → `1000:1000`,
|
||||
агент `exit_code=0`, docker.sock и ssh-деплой живы) — `15-staging-log.md`,
|
||||
гейт `check_staging_status`.
|
||||
2. Прод-рестарт под новым uid — **только в окно тишины** (P-4).
|
||||
3. Регресс на хосте: новые tracked-артефакты `1000:1000`, `git pull` под slin без ошибок.
|
||||
|
||||
## Последствия
|
||||
|
||||
**Плюсы:**
|
||||
- Корень устранён: артефакты создаются под `slin:slin`, ручной `chown` после деплоя не нужен.
|
||||
- `HOME` теперь консистентен по всем осям (uid = claude = ssh = `/home/slin`); устранён
|
||||
скрытый рассинхрон SSH-маунта (`/root/.ssh`) с форсимым HOME.
|
||||
- Минимальная поверхность изменения: декларативный compose, без нового кода в launcher.
|
||||
|
||||
**Минусы / ограничения:**
|
||||
- Появляется жёсткая привязка к `uid 1000` хоста — задокументирована в INFRA.md;
|
||||
при переносе на другой хост uid пересматривается.
|
||||
- Требуются host-prerequisites (P-1…P-4) — часть фикса не закрывается кодом; P-1 — блокер.
|
||||
- Прод-рестарт self = групповой риск (enduro-trails) → строго окно тишины (P-4),
|
||||
страховка — staging-гейт (adr-0003).
|
||||
|
||||
**Вне объёма:** массовый `chown` уже существующих `root:root` файлов в истории (разовая
|
||||
операция Owner, команда описана в INFRA.md); логика конвейера/QG/схема БД — без изменений.
|
||||
```
|
||||
47
docs/work-items/ORCH-040/07-infra-requirements.md
Normal file
47
docs/work-items/ORCH-040/07-infra-requirements.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 07 — Инфра-требования: ORCH-040
|
||||
|
||||
Work Item: **ORCH-040** · Решение: [ADR-001](06-adr/ADR-001-run-agents-as-host-uid.md) (Вариант 1)
|
||||
|
||||
> Требования к рантайму/инфре, которые Dev обязан реализовать, а Reviewer — проверить.
|
||||
> Топология стадий и БД **не меняются**. Меняется только runtime-uid контейнера и target SSH-маунта.
|
||||
|
||||
## R-1 — runtime uid контейнера
|
||||
- Оба сервиса в `docker-compose.yml` запускаются под `user: "1000:1000"`.
|
||||
- `group_add: ["999"]` **сохраняется** на обоих (docker.sock через gid 999, МИНА 1).
|
||||
|
||||
## R-2 — SSH-маунт согласован с HOME
|
||||
- target SSH-маунта = `/home/slin/.ssh` (не `/root/.ssh`) на обоих сервисах.
|
||||
- Совпадает с `HOME=/home/slin`, форсимым в `src/agents/launcher.py` (L326, L513).
|
||||
- Источник (`/home/slin/.orchestrator-ssh`) и режим `:ro` — без изменений.
|
||||
|
||||
## R-3 — claude-маунты без изменений
|
||||
- `/home/slin/.claude` (rw) и `/home/slin/.claude.json:ro` остаются.
|
||||
- Доступ под uid 1000 обеспечивается host-prerequisite P-1 (chown creds), см. ADR.
|
||||
|
||||
## R-4 — образ и launcher без изменений (по умолчанию)
|
||||
- `Dockerfile` не меняется (numeric uid не требует записи в `/etc/passwd`;
|
||||
`safe.directory '*'` уже есть). Изменение допустимо только при доказанной поломке под 1000.
|
||||
- `src/agents/launcher.py` не меняется (`HOME`, `CLAUDE_BIN` валидны под 1000).
|
||||
|
||||
## R-5 — host-prerequisites (Owner, вне кода)
|
||||
P-1…P-4 из ADR §«Host-prerequisites» — обязательная процедура. P-1 (доступ uid 1000 к
|
||||
claude creds) — блокер: без него preflight (ORCH-044) заворачивает конвейер.
|
||||
|
||||
## R-6 — порядок внедрения
|
||||
1. staging (8501) end-to-end → `15-staging-log.md` / `check_staging_status` зелёный;
|
||||
2. прод-рестарт self — только в окно тишины (`GET /status` без активных задач, P-4);
|
||||
3. регресс на хосте: новые tracked-артефакты `1000:1000`, `git pull` под slin без ошибок.
|
||||
|
||||
## R-7 — обновление документации (golden source)
|
||||
Dev в том же PR обновляет:
|
||||
- `docs/operations/INFRA.md` — блок «Тома (volumes)» (SSH target `/home/slin/.ssh`) и
|
||||
явное указание runtime-uid (`user: 1000:1000`) контейнеров; команда разового хост-`chown`
|
||||
legacy `root:root` файлов.
|
||||
- `CHANGELOG.md` — запись `fix:`/`refactor:`.
|
||||
- глобальный [adr-0005](../../architecture/adr/adr-0005-container-runs-as-host-uid.md) уже
|
||||
заведён архитектором; индекс `docs/architecture/adr/README.md` обновлён.
|
||||
|
||||
## Что НЕ требуется
|
||||
- Новых томов, портов, env-переменных — нет.
|
||||
- Изменения API, схемы БД, реестра QG/стадий — нет.
|
||||
- Multi-node / облачные сервисы — нет (принципы архитектуры).
|
||||
19
docs/work-items/ORCH-040/10-tech-risks.md
Normal file
19
docs/work-items/ORCH-040/10-tech-risks.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 10 — Технические риски: ORCH-040
|
||||
|
||||
Work Item: **ORCH-040** · Решение: [ADR-001](06-adr/ADR-001-run-agents-as-host-uid.md)
|
||||
|
||||
| # | Риск | Вероятн. | Влияние | Митигация |
|
||||
|---|------|----------|---------|-----------|
|
||||
| TR-1 | **МИНА 2 — claude creds недоступны uid 1000** → preflight (ORCH-044) валит весь конвейер | Средн. | Крит. (блокер) | P-1: `chown -R 1000:1000 /home/slin/.claude` ДО рестарта; проверка `sudo -u '#1000' test -r .../.credentials.json`; staging-прогон ловит до прода (AC-3) |
|
||||
| TR-2 | **МИНА 1 — потеря доступа к docker.sock** при смене uid → деплой-операции падают | Низк. | Высок. | `group_add: ["999"]` сохраняется на обоих сервисах (НЕ удалять); проверка `docker ps` из контейнера (AC-4) |
|
||||
| TR-3 | **SSH-маунт ведёт в чужой HOME** (`/root/.ssh`) → ssh-деплой не находит ключи | Средн. | Высок. | R-2: target → `/home/slin/.ssh`, синхронно с форсимым `HOME`; проверка деплой-хука (AC-5) |
|
||||
| TR-4 | **Рестарт prod self вне окна тишины** роняет конвейер всех проектов (enduro-trails) | Средн. | Крит. | P-4: рестарт только при `GET /status` без активных задач; страховка — staging-гейт adr-0003 (AC-7, AC-9) |
|
||||
| TR-5 | **Регресс launcher** при невалидном HOME/uid (`/home/slin` отсутствует, claude.exe не исполним) | Низк. | Высок. | `/home/slin` материализуется bind-маунтами; `claude.exe` `+x` для всех; staging end-to-end + `pytest tests/ -q` (AC-6) |
|
||||
| TR-6 | **Legacy `root:root` файлы в истории** мешают git под slin даже после фикса | Высок. | Средн. | Вне объёма задачи: разовый хост-`chown` делает Owner; команда описана в INFRA.md |
|
||||
| TR-7 | **Привязка к uid 1000 конкретного хоста** усложняет перенос на другой хост | Низк. | Низк. | Задокументировано в INFRA.md как явное допущение рантайма; пересмотр при миграции хоста |
|
||||
| TR-8 | **Запись в bind-маунты под 1000** (`/app/data`, `/repos`) при неверных правах хоста | Низк. | Средн. | P-3: `/repos` и `/app/data` уже `1000:1000` (подтверждено разведкой) |
|
||||
|
||||
## Сводный вывод
|
||||
Основной блокер — TR-1 (creds). Все критичные риски снимаются обязательным staging-прогоном
|
||||
(adr-0003) ПЕРЕД прод-рестартом и выполнением host-prerequisites P-1…P-4. Изменение
|
||||
декларативное (compose), без правок горячего кода launcher → низкая поверхность регресса.
|
||||
70
docs/work-items/ORCH-040/12-review.md
Normal file
70
docs/work-items/ORCH-040/12-review.md
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-040
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-040
|
||||
|
||||
## Summary
|
||||
Фикс переводит оба compose-сервиса (`orchestrator`, `orchestrator-staging`) на
|
||||
`user: "1000:1000"` (Вариант 1 из ADR-001 / adr-0005), чтобы артефакты конвейера
|
||||
создавались как `slin:slin` и git на хосте работал без ручного `chown`. Реализация
|
||||
точно соответствует ТЗ и ADR, документация (INFRA.md, CHANGELOG.md, work-item ADR-001,
|
||||
глобальный adr-0005) обновлена в том же PR, host-prerequisites (P-1…P-4) задокументированы.
|
||||
Полный прогон `pytest tests/ -q` — **501 passed**. Блокеров и must-fix нет.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- нет
|
||||
|
||||
### P1 — Must fix
|
||||
- нет
|
||||
|
||||
### P2 — Should fix
|
||||
- нет
|
||||
|
||||
### P3 — Nice to have
|
||||
- [ ] (опц.) AC-1/2/3/4/5 — это runtime/host-критерии; их фактическое PASS подтверждается
|
||||
на стадиях `testing` и `deploy-staging` (`15-staging-log.md`, `staging_status:`), а не
|
||||
ревью кода. Зафиксировано как ожидание к следующим стадиям, не как замечание к PR.
|
||||
|
||||
## Проверка по осям
|
||||
|
||||
**1. Соответствие ТЗ (02-trz.md §4):**
|
||||
- §4.1 `docker-compose.yml`: оба сервиса получили `user: "1000:1000"` ✅; `group_add: ["999"]`
|
||||
сохранён (МИНА 1 — не удалён) ✅; SSH-маунт target `/root/.ssh` → `/home/slin/.ssh` ✅;
|
||||
claude-маунты (`/home/slin/.claude`, `.claude.json:ro`) не тронуты ✅.
|
||||
- §4.3 `src/agents/launcher.py` не менялся; `HOME=/home/slin` остаётся на стр. 326 и 513
|
||||
(подтверждено grep) — согласован с новым SSH target ✅.
|
||||
- §4.4 `Dockerfile` не менялся (numeric uid не требует записи в `/etc/passwd`,
|
||||
`safe.directory '*'` уже есть) — в полном соответствии с решением ADR ✅.
|
||||
- §5/§6/§7: изменений API/БД/QG нет — подтверждено ✅.
|
||||
|
||||
**2. Соответствие ADR (ADR-001 + global adr-0005):**
|
||||
- Выбран и реализован Вариант 1 ровно как описано в ADR (compose-only, без нового кода
|
||||
в launcher и Dockerfile) ✅.
|
||||
- Host-prerequisites P-1…P-4 из ADR перенесены в INFRA.md как обязательная процедура Owner ✅.
|
||||
- Нарушений глобальных ADR нет; связи с adr-0003 (staging-гейт как страховка) учтены ✅.
|
||||
|
||||
**3. Качество кода:**
|
||||
- Изменения декларативные, с поясняющими комментариями и ссылкой на ADR ✅.
|
||||
- Тесты `tests/test_orch040_compose.py` содержательные: проверяют `user`, сохранение
|
||||
`group_add 999`, SSH target под HOME и согласованность HOME launcher'а с маунтами
|
||||
(TC-01…TC-04, привязаны к AC) — не тривиальные ✅.
|
||||
- Регресс отсутствует: `pytest tests/ -q` → 501 passed ✅.
|
||||
|
||||
## Документация
|
||||
Обновлена корректно и в том же PR (golden source соблюдён, AC-8 PASS):
|
||||
- `docs/operations/INFRA.md` — добавлен блок «Рантайм-uid (ORCH-040)», host-prerequisites,
|
||||
блок volumes/SSH target приведён к `/home/slin/.ssh` ✅;
|
||||
- `CHANGELOG.md` — запись в разделе Fixed ✅;
|
||||
- `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md` — выбор варианта +
|
||||
обоснование + P-1…P-4 ✅;
|
||||
- глобальный `docs/architecture/adr/adr-0005-container-runs-as-host-uid.md` (+ запись в
|
||||
`adr/README.md`) — сквозное решение зафиксировано ✅.
|
||||
|
||||
Изменения `src/` Python-кода нет (правка только в `docker-compose.yml` + тесты), но
|
||||
документация всё равно обновлена — требование §2 CLAUDE.md выполнено с запасом.
|
||||
94
docs/work-items/ORCH-040/13-test-report.md
Normal file
94
docs/work-items/ORCH-040/13-test-report.md
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-040
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-040
|
||||
|
||||
Тема: agent-файлы конвейера создаются под uid хоста (`1000:1000`, slin),
|
||||
а не `root:root`. Реализация — Вариант 1 (`user: "1000:1000"` в обоих
|
||||
compose-сервисах), правка только в `docker-compose.yml` + тесты.
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Сервис (prod 8500): `/health` → 200 `{"status":"ok"}`; preflight_ok=true (`2.1.142 (Claude Code)`)
|
||||
- Дата: 2026-06-06T15:06:25Z
|
||||
- Ветка: feature/ORCH-040-root-git
|
||||
|
||||
## Smoke test API (read-only GET, прод-контейнер не трогался)
|
||||
| Endpoint | Результат |
|
||||
|----------|-----------|
|
||||
| GET /health | 200 — `{"status":"ok","service":"orchestrator"}` |
|
||||
| GET /status | 200 — активная задача ORCH-040 (stage=testing) |
|
||||
| GET /queue | 200 — counts ok, max_concurrency=1, breaker=closed, preflight_ok=true |
|
||||
|
||||
> curl в окружении тестера отсутствует; smoke выполнен эквивалентным запросом
|
||||
> через `python -m urllib.request` (только GET, без побочных эффектов).
|
||||
|
||||
## Результаты (по 04-test-plan.yaml)
|
||||
|
||||
| TC ID | Описание | Тип | Результат |
|
||||
|-------|----------|-----|-----------|
|
||||
| TC-01 | compose: оба сервиса `user: "1000:1000"` (Вариант 1) | unit | PASS |
|
||||
| TC-02 | compose: оба сервиса сохраняют `group_add: ["999"]` (МИНА 1, docker.sock) | unit | PASS |
|
||||
| TC-03 | compose: SSH-маунт target под `/home/slin/.ssh`, согласован с HOME | unit | PASS |
|
||||
| TC-04 | launcher: форсимый HOME совместим с claude/SSH-маунтами (`/home/slin`) | unit | PASS |
|
||||
| TC-05 | полный регресс `pytest tests/` зелёный (нет регресса конвейера/launcher) | unit | PASS (501 passed) |
|
||||
| TC-06 | staging E2E: артефакты worktree/docs создаются `1000:1000` (AC-1) | integration | DEFERRED → deploy-staging |
|
||||
| TC-07 | хост под slin: `git pull/status/reset` без Permission denied (AC-2) | integration | DEFERRED → deploy-staging |
|
||||
| TC-08 | claude preflight/auth под uid 1000, агент exit_code=0 (AC-3, МИНА 2) | integration | DEFERRED → deploy-staging |
|
||||
| TC-09 | docker.sock + ssh-деплой под uid 1000 (AC-4, AC-5) | integration | DEFERRED → deploy-staging |
|
||||
|
||||
**О TC-06…TC-09:** по дизайну test-plan'а это ops/integration-проверки на
|
||||
staging (8501) и хосте, касающиеся прав ФС хоста и docker-рантайма. Они
|
||||
относятся к стадии `deploy-staging` (их PASS фиксируется в `15-staging-log.md`,
|
||||
`staging_status:`) и не воспроизводимы в окружении стадии `testing` без
|
||||
рестарта контейнера под новым uid. Это совпадает с замечанием ревью
|
||||
(12-review.md, P3): runtime/host-критерии AC-1…AC-5 подтверждаются на
|
||||
`deploy-staging`, а не при тестировании кода. Запуск деструктивных операций /
|
||||
рестарт self в рамках стадии testing запрещён (CLAUDE.md, self-hosting).
|
||||
|
||||
## Покрытие критериев приёмки (03-acceptance-criteria.md)
|
||||
| AC | Статус на стадии testing |
|
||||
|----|--------------------------|
|
||||
| AC-1 (артефакты под uid хоста) | runtime — проверяется на deploy-staging |
|
||||
| AC-2 (git под slin) | runtime — проверяется на deploy-staging |
|
||||
| AC-3 (claude preflight ok) | preflight_ok=true в `/queue`; полное E2E — deploy-staging |
|
||||
| AC-4 (docker.sock доступен) | конфиг подтверждён TC-02; runtime — deploy-staging |
|
||||
| AC-5 (SSH-деплой) | конфиг подтверждён TC-03; runtime — deploy-staging |
|
||||
| AC-6 (конвейер без регресса, pytest зелёный) | **PASS** — 501 passed |
|
||||
| AC-7 (проверено на staging до прода) | стадия deploy-staging |
|
||||
| AC-8 (документация/ADR обновлены) | **PASS** — подтверждено ревью (APPROVED) |
|
||||
| AC-9 (прод не уронен вне окна тишины) | стадия deploy/окно тишины |
|
||||
| AC-10 (host-prerequisites зафиксированы) | **PASS** — P-1…P-4 в ADR/INFRA |
|
||||
|
||||
## Вывод pytest
|
||||
```
|
||||
$ python -m pytest tests/ -v --tb=short
|
||||
platform linux -- Python 3.12.13, pytest-8.3.3, pluggy-1.6.0
|
||||
configfile: pytest.ini
|
||||
plugins: anyio-4.13.0, asyncio-0.23.8
|
||||
...
|
||||
======================== 501 passed, 1 warning in 8.54s ========================
|
||||
|
||||
$ python -m pytest tests/test_orch040_compose.py -v
|
||||
tests/test_orch040_compose.py::test_tc01_service_runs_as_host_uid[orchestrator] PASSED
|
||||
tests/test_orch040_compose.py::test_tc01_service_runs_as_host_uid[orchestrator-staging] PASSED
|
||||
tests/test_orch040_compose.py::test_tc02_group_add_keeps_docker_gid[orchestrator] PASSED
|
||||
tests/test_orch040_compose.py::test_tc02_group_add_keeps_docker_gid[orchestrator-staging] PASSED
|
||||
tests/test_orch040_compose.py::test_tc03_ssh_mount_under_home[orchestrator] PASSED
|
||||
tests/test_orch040_compose.py::test_tc03_ssh_mount_under_home[orchestrator-staging] PASSED
|
||||
tests/test_orch040_compose.py::test_tc04_launcher_home_matches_mounts PASSED
|
||||
========================= 7 passed, 1 warning in 0.31s =========================
|
||||
```
|
||||
(1 warning — Pydantic V2 deprecation в `src/config.py`, не относится к ORCH-040.)
|
||||
|
||||
## Итог
|
||||
**PASS** — все автоматизируемые тесты (TC-01…TC-05) зелёные, полный регресс
|
||||
501 passed, smoke API ok, документация/ADR подтверждены ревью. Runtime/host
|
||||
критерии (TC-06…TC-09, AC-1…AC-5/7/9) корректно отложены на обязательную
|
||||
стадию `deploy-staging` (8501) — страховку self-hosting перед прод-деплоем.
|
||||
|
||||
Задача переходит на стадию **deploy-staging**.
|
||||
37
docs/work-items/ORCH-040/15-staging-log.md
Normal file
37
docs/work-items/ORCH-040/15-staging-log.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-06T15:08:10+00:00
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed. All checks passed.
|
||||
|
||||
- **Work item:** ORCH-040
|
||||
- **Mode:** stub
|
||||
- **Execution:** canonical — `docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub` (ORCH-048, ADR-001)
|
||||
- **Result:** 10/10 checks PASS (exit code 0)
|
||||
|
||||
## Check results
|
||||
|
||||
| Check | Result | Detail |
|
||||
|-------|--------|--------|
|
||||
| A1 GET /health → 200 status=ok | PASS | body `{status: ok, service: orchestrator}` |
|
||||
| A2 GET /queue → 200 with counts/max_concurrency/resilience | PASS | keys present |
|
||||
| A3 ORCH_STAGING=true (not prod) | PASS | `ORCH_STAGING=true` |
|
||||
| B4 Plane: sandbox project accessible | PASS | found 5 project(s), sandbox=YES |
|
||||
| B5 Gitea: orchestrator-sandbox accessible, push=true | PASS | admin/push/pull=true |
|
||||
| B6 Registry: sandbox present, prod ET/ORCH absent | PASS | sandbox=YES, prod-ET=NO, prod-ORCH=NO |
|
||||
| C7 Create issue in Plane SANDBOX | PASS | HTTP 201 |
|
||||
| C8 Trigger pipeline via /webhook/plane | PASS | HTTP 200, status=accepted |
|
||||
| C9a Branch appears in orchestrator-sandbox | PASS | feature/SANDBOX-016-staging-check-e2e |
|
||||
| C9b Analyst job enqueued in staging queue | PASS | job queued, agent=analyst |
|
||||
|
||||
Cleanup (branch, Plane issue, DB rows) completed successfully via try/finally.
|
||||
|
||||
> Note: Docker CLI was unavailable in the deployer environment; the canonical
|
||||
> in-container exec was performed via the Docker Engine API over the unix socket
|
||||
> (equivalent to `docker exec`). B6 registry-isolation therefore reflects the
|
||||
> running staging instance's own `.env.staging` process-env — no host-env
|
||||
> fallback (avoids the ORCH-048 false-FAIL).
|
||||
7
docs/work-items/ORCH-042/00-business-request.md
Normal file
7
docs/work-items/ORCH-042/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: Telegram live-tracker: режим bump (карточка падает вниз при обновлении)
|
||||
|
||||
Work Item ID: ORCH-042
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
65
docs/work-items/ORCH-042/01-brd.md
Normal file
65
docs/work-items/ORCH-042/01-brd.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# 01 — BRD: Telegram live-tracker, режим bump + русификация карточки
|
||||
|
||||
**Work Item:** ORCH-042
|
||||
**Тип:** UX-улучшение (notifications)
|
||||
**Приоритет:** средний
|
||||
**Запрос:** Слава, 05.06. Связь: `feat/telegram-live-tracker` (Variant B+).
|
||||
**Self-hosting:** да — правка самого оркестратора, проходит через его же конвейер (общая БД/очередь с enduro-trails). См. `docs/operations/INFRA.md`.
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
Live-tracker задачи (`src/notifications.py`) — это ОДНА карточка на задачу в Telegram, которая обновляется на каждом переходе стадии через `editMessageText` (Variant B+). Так сделано СПЕЦИАЛЬНО, чтобы убить старую проблему «~15 отдельных карточек/дублей на задачу».
|
||||
|
||||
Побочный эффект текущего решения: карточка редактируется **на месте в истории чата**. При активной переписке в чате карточка «тонет» вверху и её неудобно искать — приходится скроллить вверх к старому сообщению, чтобы увидеть актуальный статус задачи.
|
||||
|
||||
Дополнительно накопились косметические претензии к тексту карточки: смесь англоязычных меток стадий с русским текстом, неудачная формулировка «Ревью БРД», и финальный технический хвост `deployed` вместо человекочитаемого «Внедрено».
|
||||
|
||||
## 2. Цель
|
||||
|
||||
1. Дать Славе альтернативный режим отображения трекера — **bump**: при каждом обновлении карточка «падает вниз» свежим сообщением (всегда последняя в чате), но БЕЗ возврата к проблеме дублей (по-прежнему ОДНА карточка на задачу) и БЕЗ спама звуками/пингами.
|
||||
2. Привести текст карточки к единому русскому виду и поправить формулировки.
|
||||
|
||||
## 3. Заинтересованные лица
|
||||
|
||||
- **Слава (Owner)** — единственный получатель Telegram-уведомлений; принимает UX.
|
||||
- **Агенты конвейера** — косвенно: трекер обновляется из `notify_*`-хелперов на каждой стадии.
|
||||
|
||||
## 4. Требования (бизнес-уровень)
|
||||
|
||||
### 4.1. Режим работы трекера (флаг)
|
||||
- **BR-1.** Новый конфиг-флаг `ORCH_TRACKER_MODE` с двумя значениями:
|
||||
- `edit` — текущее поведение (редактирование на месте). **Это ДЕФОЛТ** (обратная совместимость, никакой регрессии без явного включения).
|
||||
- `bump` — новый режим «карточка падает вниз».
|
||||
- **BR-2.** Неизвестное/пустое значение флага трактуется как `edit` (безопасный фолбэк, оркестратор не падает).
|
||||
|
||||
### 4.2. Поведение режима bump
|
||||
- **BR-3.** При обновлении карточки в режиме `bump`: старое сообщение удаляется (`deleteMessage`), отправляется новое (`sendMessage`), указатель `tracker_message_id` перенаправляется на новое сообщение. Итог: в чате всегда ровно ОДНА карточка задачи, и она всегда внизу.
|
||||
- **BR-4.** Bump тихий: новое сообщение отправляется с `disable_notification=true` — карточка всплывает внизу, но БЕЗ звука/пинга на каждой стадии (как и сейчас в edit-режиме).
|
||||
- **BR-5.** Первое обновление (карточки ещё нет) в режиме `bump` — просто тихо отправить новое и запомнить id (удалять нечего).
|
||||
|
||||
### 4.3. Устойчивость (критично — не сломать защиту от дублей)
|
||||
- **BR-6.** Fallback: если `deleteMessage` не удался (сообщение старше 48 ч / уже удалено / недоступно) — карточка всё равно отправляется заново, оркестратор НЕ падает.
|
||||
- **BR-7.** Любой сбой нотификации (сеть/таймаут/5xx/Telegram-ошибка) НЕ роняет оркестратор (контракт «never raises» сохраняется) и НЕ плодит дубли карточек в пределах одного обновления.
|
||||
- **BR-8.** Режим `edit` после изменений работает строго как раньше — без регрессий (защита от ~15 дублей сохранена).
|
||||
|
||||
### 4.4. Текстовые правки карточки (применяются в ОБОИХ режимах)
|
||||
- **BR-9.** Метку «Ревью БРД» заменить на «Подтверждение BRD».
|
||||
- **BR-10.** После того как задача переведена в Approved (человеческий gate пройден, время ревью зафиксировано), эмодзи в строке подтверждения BRD заменить на галочку (✅) вместо текущей паузы (⏸️). Пока ждём человека — оставить прежний индикатор ожидания.
|
||||
- **BR-11.** Русифицировать метки стадий карточки: `Analysis → Анализ`, `Architecture → Архитектура`, `Development → Разработка`, `Review → Код ревью`, `Testing → Тестирование`, `Deploy → Внедрение`.
|
||||
- **BR-12.** В итоговой (последней) строке готовой задачи заменить технический `deployed` на «Внедрено».
|
||||
|
||||
## 5. Вне scope
|
||||
- Изменение состава событий, которые шлются ОТДЕЛЬНЫМИ пингами (approve-gate / deploy-fail / agent-fail / error) — остаётся как есть.
|
||||
- Изменение формата метрик (токены/стоимость/длительность), макета строк, логики «попытка N».
|
||||
- Любые изменения в Plane-комментариях агентов (`usage.build_status_comment`).
|
||||
- Хранение истории карточек / несколько карточек на задачу.
|
||||
|
||||
## 6. Влияние на документацию (golden source)
|
||||
- `CHANGELOG.md` — запись в `[Unreleased]`.
|
||||
- `docs/architecture/internals.md` (или соответствующая секция про live-tracker) — описать режимы `edit`/`bump` и `ORCH_TRACKER_MODE`.
|
||||
- `.env.example` — добавить `ORCH_TRACKER_MODE` с пояснением.
|
||||
|
||||
## 7. Критерии успеха (резюме)
|
||||
Слава может выставить `ORCH_TRACKER_MODE=bump` и видеть актуальную карточку всегда внизу чата, одну на задачу, без звона; при откате на `edit` (дефолт) поведение неотличимо от текущего; текст карточки полностью русифицирован по BR-9..BR-12. Полные условия PASS/FAIL — `03-acceptance-criteria.md`.
|
||||
</content>
|
||||
</invoke>
|
||||
118
docs/work-items/ORCH-042/02-trz.md
Normal file
118
docs/work-items/ORCH-042/02-trz.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# 02 — ТЗ: Telegram live-tracker, режим bump + русификация
|
||||
|
||||
**Work Item:** ORCH-042 · См. `01-brd.md`, `03-acceptance-criteria.md`.
|
||||
|
||||
## 1. Задействованные модули `src/`
|
||||
| Файл | Что меняется |
|
||||
|------|--------------|
|
||||
| `src/config.py` | Новое поле `Settings.tracker_mode` (env `ORCH_TRACKER_MODE`). |
|
||||
| `src/notifications.py` | Новый helper `delete_telegram(message_id)`; ветвление `update_task_tracker` по режиму; текстовые правки в `_BRD_LABEL`, `_TRACKER_STAGES`, BRD-строке `render_task_tracker`, `_done_link`. |
|
||||
|
||||
БД — **без изменений** (используется существующая колонка `tasks.tracker_message_id` и хелперы `get_tracker_message_id` / `set_tracker_message_id` в `src/db.py`). API HTTP-эндпоинты оркестратора — **без изменений**. Новые QG checks — **не требуются**.
|
||||
|
||||
## 2. Изменения конфигурации (`src/config.py`)
|
||||
|
||||
Добавить в класс `Settings` (рядом с блоком «Telegram notifications»):
|
||||
|
||||
```python
|
||||
# ORCH-042: режим live-трекера задачи.
|
||||
# edit -> карточка редактируется на месте (editMessageText), ДЕФОЛТ (как было).
|
||||
# bump -> при обновлении старое сообщение удаляется и карточка отправляется
|
||||
# заново вниз чата (deleteMessage + sendMessage + repoint message_id),
|
||||
# тихо (disable_notification). Одна карточка на задачу в обоих режимах.
|
||||
# Неизвестное/пустое значение трактуется как edit (см. notifications).
|
||||
tracker_mode: str = "edit"
|
||||
```
|
||||
|
||||
- `env_prefix = "ORCH_"` уже задан → переменная окружения `ORCH_TRACKER_MODE`.
|
||||
- Резолюция режима — в `notifications`: всё, что не равно (case-insensitive, trimmed) `"bump"`, считается `edit`. Не падать на любом значении.
|
||||
|
||||
## 3. Изменения нотификаций (`src/notifications.py`)
|
||||
|
||||
### 3.1. Новый low-level helper `delete_telegram`
|
||||
Рядом с `send_telegram` / `edit_telegram`. Контракт «never raises».
|
||||
|
||||
```python
|
||||
def delete_telegram(message_id: int) -> bool:
|
||||
"""Delete a Telegram message. Never raises.
|
||||
|
||||
Returns True if the message is gone after the call (deleted now, OR Telegram
|
||||
says it's already not there / can't be deleted -> treat as "no longer our
|
||||
problem", caller proceeds to send a fresh card). Returns False only on a
|
||||
transient failure (network / timeout / 5xx / unknown error) where the old
|
||||
message may still be alive.
|
||||
"""
|
||||
```
|
||||
|
||||
Требования к реализации:
|
||||
- Эндпоинт `https://api.telegram.org/bot{token}/deleteMessage`, тело `{chat_id, message_id}`, `timeout=5`.
|
||||
- Нет токена/chat_id → вернуть `False` (как и прочие helpers при отсутствии кредов — ничего не отправлено, ничего не удалено).
|
||||
- `ok:true` → `True`.
|
||||
- `ok:false` с описанием «уже нет / нельзя удалить» (маркеры: `"message to delete not found"`, `"message can't be deleted"`, `"message_id_invalid"`) → `True` (сообщение и так недоступно; не транзиент).
|
||||
- Прочие `ok:false` (неизвестный 400 / 5xx) и исключения (сеть/таймаут) → `False` + `logger.warning`.
|
||||
- Вынести маркеры в модульную константу (по аналогии с `_GONE_MARKERS`), например `_DELETE_GONE_MARKERS`.
|
||||
|
||||
### 3.2. Ветвление `update_task_tracker` по режиму
|
||||
Сохранить существующий путь `edit` без изменений поведения. Добавить путь `bump`.
|
||||
|
||||
Псевдокод целевой логики:
|
||||
```python
|
||||
def update_task_tracker(task_id: int):
|
||||
try:
|
||||
from .db import get_tracker_message_id, set_tracker_message_id
|
||||
text = render_task_tracker(task_id)
|
||||
mode = (_get_settings().tracker_mode or "edit").strip().lower()
|
||||
mid = get_tracker_message_id(task_id)
|
||||
|
||||
if mode == "bump":
|
||||
# bump: одна карточка, но всегда внизу.
|
||||
if mid is not None:
|
||||
delete_telegram(mid) # best-effort; fallback -> всё равно шлём новое
|
||||
new_mid = send_telegram(text, disable_notification=True)
|
||||
if new_mid is not None:
|
||||
set_tracker_message_id(task_id, new_mid)
|
||||
# send вернул None (нет кредов / транзиент) -> mid не трогаем,
|
||||
# дубля в пределах вызова нет; перерисуется на следующем переходе.
|
||||
return
|
||||
|
||||
# mode == "edit" (ДЕФОЛТ): существующая логика без изменений.
|
||||
... # текущий код edit/EDIT_GONE-fallback as is
|
||||
except Exception as e:
|
||||
logger.warning(f"update_task_tracker({task_id}) failed: {e}")
|
||||
```
|
||||
|
||||
Инварианты bump-ветки:
|
||||
- В пределах ОДНОГО вызова отправляется максимум одно новое сообщение → дублей нет (BR-7).
|
||||
- `set_tracker_message_id` вызывается ТОЛЬКО при успешном `send` (`new_mid is not None`). При сбое send id остаётся прежним; на следующем переходе старый будет удалён (или уже мёртв) и отправлен новый — без накопления карточек.
|
||||
- `delete_telegram` — best-effort: его результат НЕ блокирует отправку новой карточки (BR-6: delete-fail → всё равно шлём новое).
|
||||
- Bump всегда тихий: `disable_notification=True` (BR-4).
|
||||
|
||||
### 3.3. Текстовые правки (общие для обоих режимов)
|
||||
|
||||
| BR | Где | Было | Стало |
|
||||
|----|-----|------|-------|
|
||||
| BR-9 | `_BRD_LABEL` (модульная константа) | `"Ревью БРД"` | `"Подтверждение BRD"` |
|
||||
| BR-10 | `render_task_tracker`, ветка BRD-строки при `review_seconds is not None` | префикс `⏸️` (`⏸️`) | `✅` (`✅`). Ветка ожидания (`review_seconds is None`, с ⏳) — НЕ менять. |
|
||||
| BR-11 | `_TRACKER_STAGES` (метки) | `Analysis / Architecture / Development / Review / Testing / Deploy` | `Анализ / Архитектура / Разработка / Код ревью / Тестирование / Внедрение` |
|
||||
| BR-12 | `_done_link` | `"\U0001f4e6 deployed"` | `"\U0001f4e6 Внедрено"` |
|
||||
|
||||
Примечания:
|
||||
- В `_TRACKER_STAGES` меняется ТОЛЬКО display-label (2-й элемент кортежа). Ключи стадий (`analysis`,…) и имена агентов (`analyst`,…) НЕ трогать — они завязаны на `_STAGE_ACTIVE_AGENT`, `last_done`, БД.
|
||||
- Выравнивание `{label:<13}` и `{_BRD_LABEL:<13}` оставить как есть (все новые русские метки ≤13 символов; «Подтверждение BRD» длиннее — формат просто не паддит, косметика, поведение не ломает).
|
||||
- Метки используются и в «✅ …»-строках завершённых стадий, и в «🔄 … идёт»-строке активной стадии — обе автоматически станут русскими (правка в одном месте).
|
||||
|
||||
## 4. Совместимость и риски
|
||||
- Дефолт `edit` гарантирует нулевую регрессию без явного включения bump (BR-8). Подробно — `10-tech-risks.md` (заводит архитектор/девелопер при необходимости).
|
||||
- Самохостинг: изменения только в коде нотификаций, миграций БД нет, перезапуск self — по стандартной страховке `deploy-staging` (8501) перед prod (см. `CLAUDE.md`).
|
||||
|
||||
## 5. Артефакты pipeline, которые ДОЛЖНЫ быть обновлены в этом же PR
|
||||
- `CHANGELOG.md` → запись в `[Unreleased] / Added` (режим bump) + `Changed` (русификация текста).
|
||||
- `docs/architecture/internals.md` — секция про live-tracker: режимы `edit`/`bump`, `ORCH_TRACKER_MODE`, контракт `delete_telegram`.
|
||||
- `.env.example` — `ORCH_TRACKER_MODE=edit` с комментарием.
|
||||
- Тесты — см. `04-test-plan.yaml`. **Существующие тесты в `tests/test_telegram_tracker.py`, проверяющие англоязычные метки (`"✅ Analysis"`, `"🔄 Deploy"`, `"Review"`) и метку `"Ревью БРД"`, ОБЯЗАТЕЛЬНО обновить под новые русские строки** — иначе регрессия в CI. Это правка существующих ассертов, не изменение контракта.
|
||||
|
||||
## 6. Замечания по реализации (без расширения scope)
|
||||
- Не вводить новых зависимостей; `httpx` уже используется.
|
||||
- Не менять сигнатуры `send_telegram` / `edit_telegram` / `update_task_tracker` (внешние вызовы из `launcher`/`stage_engine` не трогаются).
|
||||
- Не менять состав отдельных пингов (approve-gate / error / deploy-fail / agent-fail).
|
||||
</content>
|
||||
55
docs/work-items/ORCH-042/03-acceptance-criteria.md
Normal file
55
docs/work-items/ORCH-042/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 03 — Критерии приёмки: ORCH-042
|
||||
|
||||
Каждый критерий — однозначное условие PASS/FAIL. Покрытие тестами — `04-test-plan.yaml`.
|
||||
|
||||
## Конфигурация
|
||||
- **AC-1.** `Settings.tracker_mode` существует, дефолт `"edit"`, читается из env `ORCH_TRACKER_MODE`.
|
||||
- PASS: `Settings().tracker_mode == "edit"` без env; `ORCH_TRACKER_MODE=bump` → `"bump"`.
|
||||
- FAIL: поле отсутствует / другой дефолт / не читает env.
|
||||
- **AC-2.** Неизвестное/пустое значение режима трактуется как `edit` (оркестратор не падает).
|
||||
- PASS: `ORCH_TRACKER_MODE=garbage` (или пусто) → `update_task_tracker` идёт по edit-ветке, исключений нет.
|
||||
- FAIL: исключение / выбор bump-ветки на мусоре.
|
||||
|
||||
## Режим edit (регрессия — поведение как было)
|
||||
- **AC-3.** Первый вызов (нет `tracker_message_id`): `sendMessage` тихо (`disable_notification=True`), id сохраняется; `editMessageText` НЕ вызывается.
|
||||
- **AC-4.** Повторный вызов при живом сообщении: `editMessageText` на сохранённый id; новое сообщение НЕ шлётся.
|
||||
- **AC-5.** `edit` вернул `EDIT_GONE` → шлётся НОВОЕ сообщение, id обновляется (fallback как раньше).
|
||||
- **AC-6.** `edit` вернул `EDIT_NOT_MODIFIED` или `EDIT_FAILED` → новое сообщение НЕ шлётся, id не меняется (защита от дублей сохранена).
|
||||
- Все AC-3..AC-6 проверяются при `tracker_mode="edit"` (дефолт). FAIL — любое расхождение с текущим поведением.
|
||||
|
||||
## Режим bump
|
||||
- **AC-7.** Первый вызов в `bump` (нет id): `deleteMessage` НЕ вызывается; `sendMessage` тихо (`disable_notification=True`); возвращённый id сохраняется.
|
||||
- PASS: ровно один `send_telegram(..., disable_notification=True)`, `delete_telegram` не вызван, `get_tracker_message_id == new_id`.
|
||||
- FAIL: вызван delete / громкое сообщение / id не сохранён.
|
||||
- **AC-8.** Повторный вызов в `bump` при существующем id: вызывается `delete_telegram(старый_id)`, затем `send_telegram(..., disable_notification=True)`, затем `tracker_message_id` перенаправляется на новый id.
|
||||
- PASS: порядок delete→send соблюдён, id == новый.
|
||||
- FAIL: нет delete / нет send / id остался старым.
|
||||
- **AC-9.** Bump тихий: новое сообщение всегда с `disable_notification=True`.
|
||||
- FAIL: `disable_notification` False/отсутствует.
|
||||
- **AC-10.** Одна карточка на задачу: за один вызов `update_task_tracker` в bump шлётся НЕ более одного нового сообщения.
|
||||
- FAIL: более одного `send_telegram` за вызов.
|
||||
|
||||
## Устойчивость
|
||||
- **AC-11.** Fallback при delete-fail: если `delete_telegram` вернул False (старое >48ч / транзиент) — новое сообщение всё равно отправляется, id обновляется, исключений нет.
|
||||
- PASS: `delete_telegram→False` → ровно один send → id == новый.
|
||||
- FAIL: send пропущен / исключение всплыло.
|
||||
- **AC-12.** `delete_telegram` классификация (httpx замокан, never raises):
|
||||
- `ok:true` → `True`;
|
||||
- `ok:false` с `"message to delete not found"` / `"message can't be deleted"` / `"message_id_invalid"` → `True`;
|
||||
- неизвестный `ok:false` / 5xx → `False`;
|
||||
- исключение (таймаут/сеть) → `False`;
|
||||
- нет токена/chat_id → `False`, HTTP-вызов не выполняется.
|
||||
- **AC-13.** Транзиентный сбой send в bump (send вернул None): `tracker_message_id` НЕ затирается на None; исключений нет; дублей нет (≤1 попытка send за вызов).
|
||||
- **AC-14.** `update_task_tracker` никогда не выбрасывает исключение ни в одном режиме (контракт «never raises») при любых сбоях БД/сети/Telegram.
|
||||
|
||||
## Текстовые правки (оба режима)
|
||||
- **AC-15.** Метка «Подтверждение BRD» присутствует в карточке там, где раньше была «Ревью БРД»; строки «Ревью БРД» в выводе нет.
|
||||
- **AC-16.** После прохождения approve-gate (зафиксированы `brd_review_started_at` и `brd_review_ended_at`) строка подтверждения BRD начинается с ✅ (не ⏸️). Пока ждём человека (`brd_review_ended_at` пуст) — индикатор ожидания/⏳ сохраняется (не ✅).
|
||||
- **AC-17.** Метки стадий в карточке русские: `Анализ`, `Архитектура`, `Разработка`, `Код ревью`, `Тестирование`, `Внедрение`. Английских меток (`Analysis`/`Architecture`/`Development`/`Review`/`Testing`/`Deploy`) в выводе нет — ни в «✅ …»-строках, ни в «🔄 … идёт».
|
||||
- **AC-18.** Итоговая строка готовой задачи содержит «📦 Внедрено» (не «deployed»).
|
||||
|
||||
## Регрессия и качество
|
||||
- **AC-19.** Состав отдельных пингов не изменён: `notify_approve_requested` шлёт ровно один НЕтихий пинг и стартует BRD-часы; `notify_error` — один НЕтихий пинг; `notify_stage_change` / `notify_agent_started` / `notify_qg_failure` — НЕ шлют отдельных сообщений (только refresh трекера).
|
||||
- **AC-20.** Вся существующая и новая pytest-сюита зелёная (`pytest tests/ -q`). Существующие ассерты в `tests/test_telegram_tracker.py` обновлены под русские метки и «Подтверждение BRD».
|
||||
- **AC-21.** Документация обновлена в ТОМ ЖЕ PR: `CHANGELOG.md`, `docs/architecture/internals.md` (режимы + `ORCH_TRACKER_MODE` + `delete_telegram`), `.env.example` (`ORCH_TRACKER_MODE`). Отсутствие — REQUEST_CHANGES на ревью.
|
||||
</content>
|
||||
160
docs/work-items/ORCH-042/04-test-plan.yaml
Normal file
160
docs/work-items/ORCH-042/04-test-plan.yaml
Normal file
@@ -0,0 +1,160 @@
|
||||
work_item: ORCH-042
|
||||
description: >
|
||||
Режим bump live-трекера (delete+send+repoint, тихо, fallback, never-raises),
|
||||
сохранение режима edit без регрессий, и текстовые правки карточки
|
||||
(Подтверждение BRD, ✅ после approve, русские метки стадий, «Внедрено»).
|
||||
Сеть не трогаем: httpx / низкоуровневые helpers мокаются; изолированная temp-БД.
|
||||
|
||||
tests:
|
||||
# --- config ---
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "Settings.tracker_mode по умолчанию 'edit' и читается из ORCH_TRACKER_MODE (AC-1)"
|
||||
module: tests/test_config.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "Неизвестное/пустое значение режима -> update_task_tracker идёт по edit-ветке, без исключений (AC-2)"
|
||||
module: tests/test_telegram_tracker.py
|
||||
expected: PASS
|
||||
|
||||
# --- edit mode regression ---
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "edit: первый вызов -> sendMessage тихо, id сохранён, editMessageText не вызван (AC-3)"
|
||||
module: tests/test_telegram_tracker.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "edit: повторный вызов -> editMessageText на сохранённый id, нового send нет (AC-4)"
|
||||
module: tests/test_telegram_tracker.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "edit: EDIT_GONE -> отправка нового, id обновлён (AC-5)"
|
||||
module: tests/test_telegram_tracker.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "edit: EDIT_NOT_MODIFIED и EDIT_FAILED -> нового сообщения нет, id не меняется (AC-6)"
|
||||
module: tests/test_telegram_tracker.py
|
||||
expected: PASS
|
||||
|
||||
# --- bump mode ---
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "bump: первый вызов (нет id) -> delete не вызван, send тихий, id сохранён (AC-7, AC-9)"
|
||||
module: tests/test_tracker_bump.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "bump: повторный вызов -> delete(старый) затем send(тихо), id перенаправлен на новый, порядок delete->send (AC-8, AC-9, AC-10)"
|
||||
module: tests/test_tracker_bump.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "bump fallback: delete_telegram->False -> новое всё равно отправлено, id обновлён, без исключений (AC-11)"
|
||||
module: tests/test_tracker_bump.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "bump: send вернул None (транзиент) -> id не затёрт на None, ровно одна попытка send, без исключений (AC-13)"
|
||||
module: tests/test_tracker_bump.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-11
|
||||
type: unit
|
||||
description: "bump: одна карточка за вызов -> send_telegram вызван <=1 раза (AC-10)"
|
||||
module: tests/test_tracker_bump.py
|
||||
expected: PASS
|
||||
|
||||
# --- delete_telegram classification ---
|
||||
- id: TC-12
|
||||
type: unit
|
||||
description: "delete_telegram: ok:true -> True (httpx замокан)"
|
||||
module: tests/test_tracker_bump.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-13
|
||||
type: unit
|
||||
description: "delete_telegram: ok:false 'message to delete not found' / 'message can't be deleted' / 'message_id_invalid' -> True (AC-12)"
|
||||
module: tests/test_tracker_bump.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-14
|
||||
type: unit
|
||||
description: "delete_telegram: неизвестный ok:false / 5xx -> False (AC-12)"
|
||||
module: tests/test_tracker_bump.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-15
|
||||
type: unit
|
||||
description: "delete_telegram: исключение (таймаут/сеть) -> False, never raises (AC-12, AC-14)"
|
||||
module: tests/test_tracker_bump.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-16
|
||||
type: unit
|
||||
description: "delete_telegram: нет токена/chat_id -> False, HTTP не вызывается (AC-12)"
|
||||
module: tests/test_tracker_bump.py
|
||||
expected: PASS
|
||||
|
||||
# --- never raises ---
|
||||
- id: TC-17
|
||||
type: unit
|
||||
description: "update_task_tracker никогда не бросает (DB/сеть сбой) в обоих режимах (AC-14)"
|
||||
module: tests/test_tracker_bump.py
|
||||
expected: PASS
|
||||
|
||||
# --- text changes ---
|
||||
- id: TC-18
|
||||
type: unit
|
||||
description: "render: метка 'Подтверждение BRD' присутствует, 'Ревью БРД' отсутствует (AC-15)"
|
||||
module: tests/test_telegram_tracker.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-19
|
||||
type: unit
|
||||
description: "render: approve-gate пройден (brd_review_ended_at задан) -> строка BRD с ✅, не ⏸️ (AC-16)"
|
||||
module: tests/test_telegram_tracker.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-20
|
||||
type: unit
|
||||
description: "render: ожидание человека (brd_review_ended_at пуст) -> индикатор ожидания/⏳, не ✅ (AC-16)"
|
||||
module: tests/test_telegram_tracker.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-21
|
||||
type: unit
|
||||
description: "render: русские метки стадий (Анализ/Архитектура/Разработка/Код ревью/Тестирование/Внедрение), английских нет — в ✅- и 🔄-строках (AC-17)"
|
||||
module: tests/test_telegram_tracker.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-22
|
||||
type: unit
|
||||
description: "render done: итоговая строка содержит '📦 Внедрено', не 'deployed' (AC-18)"
|
||||
module: tests/test_telegram_tracker.py
|
||||
expected: PASS
|
||||
|
||||
# --- separate alerts regression ---
|
||||
- id: TC-23
|
||||
type: unit
|
||||
description: "Состав отдельных пингов не изменён: approve-gate/error шлют 1 нетихий пинг; stage_change/agent_started/qg_failure не шлют (AC-19)"
|
||||
module: tests/test_telegram_tracker.py
|
||||
expected: PASS
|
||||
|
||||
# --- full suite ---
|
||||
- id: TC-24
|
||||
type: integration
|
||||
description: "Вся pytest-сюита зелёная; обновлённые ассерты под русские метки проходят (AC-20)"
|
||||
module: tests/
|
||||
expected: PASS
|
||||
</content>
|
||||
85
docs/work-items/ORCH-042/06-adr/ADR-001-tracker-bump-mode.md
Normal file
85
docs/work-items/ORCH-042/06-adr/ADR-001-tracker-bump-mode.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# ADR-001: Режим bump live-трекера через delete+send+repoint, edit как дефолт
|
||||
|
||||
**Work Item:** ORCH-042 · См. `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `10-tech-risks.md`.
|
||||
|
||||
## Статус
|
||||
Accepted
|
||||
|
||||
## Контекст
|
||||
|
||||
Live-tracker (`src/notifications.py`, ветка `feat/telegram-live-tracker`, Variant B+) держит **ОДНУ** карточку на задачу и редактирует её на месте (`editMessageText`) на каждом переходе стадии. Это сознательно убило прежнюю боль — «~15 отдельных карточек/дублей на задачу». Защита от дублей — главный инвариант компонента и не должна регрессировать.
|
||||
|
||||
Побочный эффект edit-режима: при активной переписке в чате карточка «тонет» вверху истории — актуальный статус задачи приходится искать скроллом. Слава просит альтернативу: карточка должна всегда быть последней в чате, но без возврата дублей и без звона на каждой стадии.
|
||||
|
||||
Дополнительно — косметика текста карточки (смесь EN-меток стадий с RU-текстом, «Ревью БРД», технический хвост `deployed`). Текстовые правки тривиальны и сами по себе архитектурного решения не требуют; ключевое решение — как реализовать новый режим, не сломав инвариант «одна карточка».
|
||||
|
||||
Ограничения окружения (см. `CLAUDE.md`, `docs/operations/INFRA.md`):
|
||||
- Контракт компонента: `update_task_tracker` и low-level helpers **никогда не бросают** (сбой нотификации не должен валить конвейер).
|
||||
- Self-hosting: правка инструмента, который сейчас в проде и обслуживает другие проекты из общей БД/очереди. Прод-рестарт self — только через `deploy-staging` (8501).
|
||||
- Telegram Bot API: `deleteMessage` не работает для сообщений старше 48 ч и для уже удалённых/недоступных — это нормальный ожидаемый исход, а не ошибка.
|
||||
|
||||
## Решение
|
||||
|
||||
### Р-1. Поведение задаётся конфиг-флагом, дефолт `edit` (нулевая регрессия)
|
||||
Новое поле `Settings.tracker_mode` (env `ORCH_TRACKER_MODE`), значения `edit` | `bump`, **дефолт `edit`**. Резолюция режима — в `notifications`, case-insensitive + trim; всё, что не равно `"bump"` (включая пустое/мусор/None), трактуется как `edit`. Без явного включения bump поведение неотличимо от текущего → нулевая регрессия и безопасный фолбэк (оркестратор не падает на любом значении флага).
|
||||
|
||||
### Р-2. Режим bump = delete + send + repoint, инвариант «одна карточка» сохраняется иначе
|
||||
edit-режим держит одну карточку, *редактируя* её. bump держит одну карточку, *пересоздавая* её внизу:
|
||||
1. если сохранён `tracker_message_id` — best-effort `delete_telegram(старый_id)`;
|
||||
2. `send_telegram(text, disable_notification=True)` — новая карточка внизу, тихо;
|
||||
3. при успехе (`new_mid is not None`) — `set_tracker_message_id` перенаправляется на новый id.
|
||||
|
||||
Итог: в чате всегда ровно одна карточка задачи, и она всегда последняя. За **один** вызов `update_task_tracker` отправляется **не более одного** нового сообщения → дублей в пределах вызова нет.
|
||||
|
||||
### Р-3. delete — best-effort, никогда не блокирует отправку новой карточки
|
||||
Новый low-level helper `delete_telegram(message_id) -> bool` с контрактом «never raises». Семантика возврата — «исчезло ли старое сообщение»:
|
||||
- `ok:true` → `True`;
|
||||
- `ok:false` с маркерами «уже нет / нельзя удалить» (`message to delete not found`, `message can't be deleted`, `message_id_invalid`, вынести в константу `_DELETE_GONE_MARKERS`) → `True` (не транзиент, сообщение и так недоступно);
|
||||
- прочий `ok:false` / 5xx / исключение (сеть/таймаут) → `False` + `logger.warning`;
|
||||
- нет токена/chat_id → `False`, HTTP не выполняется.
|
||||
|
||||
**Результат `delete_telegram` НЕ влияет на решение отправлять новую карточку** — её шлём всегда (BR-6: delete-fail у сообщения >48 ч → всё равно новое). `False` означает лишь «старое, возможно, ещё живо»; на следующем переходе оно будет удалено повторно (или уже мёртво). Накопления карточек это не даёт, т.к. указатель всегда хранит ровно один id.
|
||||
|
||||
### Р-4. repoint только при успешном send (анти-затирание указателя)
|
||||
`set_tracker_message_id` вызывается **только** при `new_mid is not None`. Если send вернул None (нет кредов / транзиент 5xx/таймаут) — id **не трогаем** (не затираем на None): карточка перерисуется на следующем переходе, дубля нет (≤1 попытка send за вызов). Это симметрично существующему edit-fallback, который тоже не плодит сообщения при транзиенте.
|
||||
|
||||
### Р-5. bump всегда тихий
|
||||
Новая карточка отправляется с `disable_notification=True` — всплывает внизу, но без звука/пинга, как и edit сейчас. Состав отдельных НЕтихих пингов (approve-gate / error / deploy-fail / agent-fail) не меняется (вне scope).
|
||||
|
||||
### Р-6. Текстовые правки — в одной точке, общие для обоих режимов
|
||||
Правки (`_BRD_LABEL` → «Подтверждение BRD»; ✅ вместо ⏸️ после approve-gate; русские display-labels в `_TRACKER_STAGES`; `_done_link` → «Внедрено») затрагивают только **отображаемые** строки. Ключи стадий (`analysis`, …) и имена агентов (`analyst`, …) НЕ меняются — они завязаны на `_STAGE_ACTIVE_AGENT`, `last_done`, БД. Правка `_TRACKER_STAGES` в одном месте автоматически русифицирует и «✅ …», и «🔄 … идёт».
|
||||
|
||||
### Что НЕ меняется (границы решения)
|
||||
- БД: миграций нет, используется существующая колонка `tasks.tracker_message_id` и хелперы `get_tracker_message_id` / `set_tracker_message_id`. → `08-data-requirements.md` не требуется.
|
||||
- Инфраструктура / топология / порты / контейнеры — без изменений. → `07-infra-requirements.md` не требуется.
|
||||
- State machine (`src/stages.py`), реестр QG (`src/qg/checks.py`), стадии, компоненты — без изменений. → глобальный (cross-cutting) ADR не требуется, решение локально для компонента notifications.
|
||||
- Сигнатуры `send_telegram` / `edit_telegram` / `update_task_tracker` — без изменений (внешние вызовы из `launcher`/`stage_engine` не трогаются).
|
||||
- Новых зависимостей нет (`httpx` уже используется).
|
||||
|
||||
## Альтернативы
|
||||
|
||||
- **A1. Только bump, без флага.** Отклонено: ломает обратную совместимость и единственного пользователя (Слава может предпочесть edit); рост риска регрессии защиты от дублей. Флаг с дефолтом `edit` даёт мгновенный откат.
|
||||
- **A2. Pin-сообщение (закрепить карточку).** Отклонено: pin не решает «карточка внизу при переписке», шлёт системное уведомление о закреплении (звон), и усложняет API-контракт. Вне духа «тихого» трекера.
|
||||
- **A3. send-then-delete (сначала новое, потом удалить старое).** Отклонено как дефолтный порядок: в окне между send и delete в чате видны ДВЕ карточки; при падении на delete остаётся осиротевшая старая → визуальный дубль. delete-then-send гарантирует ≤1 карточку в любой момент при нормальном пути и ≤1 *новую* отправку за вызов в любом случае.
|
||||
- **A4. Хранить историю/несколько карточек.** Вне scope и противоречит исходному инварианту «одна карточка».
|
||||
|
||||
## Последствия
|
||||
|
||||
**Плюсы**
|
||||
- Слава получает актуальную карточку всегда внизу чата, одну на задачу, без звона.
|
||||
- Нулевая регрессия по умолчанию (edit), мгновенный откат флагом.
|
||||
- Контракт «never raises» и инвариант «одна карточка» сохранены в обоих режимах.
|
||||
- Изменения локальны (`config.py` + `notifications.py`), без миграций и без рестарта-критичных зависимостей.
|
||||
|
||||
**Минусы / ограничения**
|
||||
- bump расходует Telegram API на 2 запроса вместо 1 (delete + send) на переход — для одного получателя несущественно (rate-limit Telegram не угрожает).
|
||||
- При транзиентном delete-fail возможна кратко осиротевшая старая карточка до следующего перехода (она будет вычищена попыткой delete на следующем апдейте) — приемлемо, дублей всё равно не плодит.
|
||||
- bump теряет визуальную «эволюцию на месте» edit-режима (история чата получает по карточке-замене) — но в чате всегда одна актуальная, что и требуется.
|
||||
|
||||
**Риски** — см. `10-tech-risks.md`.
|
||||
|
||||
## Связи
|
||||
- BRD/ТЗ/AC: `docs/work-items/ORCH-042/01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`; тест-план `04-test-plan.yaml`.
|
||||
- Компонент: live-tracker (`src/notifications.py`), `feat/telegram-live-tracker` (Variant B+).
|
||||
- Контекст self-hosting / staging-страховка: `CLAUDE.md`, `docs/operations/INFRA.md`, `docs/architecture/adr/adr-0003-staging-gate.md`.
|
||||
- Обновляемая дока (в том же PR, стадия development): `CHANGELOG.md`, `docs/architecture/internals.md` (секция live-tracker: режимы + `ORCH_TRACKER_MODE` + `delete_telegram`), `.env.example`.
|
||||
21
docs/work-items/ORCH-042/10-tech-risks.md
Normal file
21
docs/work-items/ORCH-042/10-tech-risks.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# 10 — Технические риски: ORCH-042
|
||||
|
||||
См. `02-trz.md`, `06-adr/ADR-001-tracker-bump-mode.md`, `03-acceptance-criteria.md`.
|
||||
|
||||
Шкала: Вероятность × Влияние ∈ {низк., сред., выс.}.
|
||||
|
||||
| # | Риск | Вер. | Влияние | Митигация | Контроль (AC/TC) |
|
||||
|---|------|------|---------|-----------|-------------------|
|
||||
| R-1 | **Регрессия защиты от дублей** — рефактор `update_task_tracker` ломает edit-ветку, возвращается боль «~15 карточек». | низк. | выс. | edit — дефолт и неизменяемая ветка; bump добавляется отдельной веткой `if mode == "bump"`, edit-код не трогается. Полное покрытие edit-регрессии тестами. | AC-3..AC-6, AC-8; TC-03..TC-06, TC-24 |
|
||||
| R-2 | **Двойная отправка / накопление карточек в bump** — delete и send рассинхронизированы, в чате >1 карточки. | низк. | сред. | Инвариант: ≤1 `send_telegram` за вызов; `set_tracker_message_id` только при успешном send; delete best-effort и не блокирует. | AC-8, AC-10, AC-11; TC-08, TC-09, TC-11 |
|
||||
| R-3 | **Затирание `tracker_message_id` на None** при транзиентном send-fail → потеря указателя, следующий апдейт не найдёт старое. | низк. | сред. | repoint только при `new_mid is not None`; при None id сохраняется как есть. | AC-13; TC-10 |
|
||||
| R-4 | **Нарушение контракта «never raises»** — исключение из `delete_telegram`/новой ветки валит конвейер (групповой риск из-за общей очереди). | низк. | выс. | `delete_telegram` обёрнут try/except → bool; внешний try/except в `update_task_tracker` сохранён; сеть/httpx мокаются в тестах. | AC-12, AC-14; TC-12..TC-17 |
|
||||
| R-5 | **Ложная классифик. delete-ответа** — неизвестный `ok:false` принят за «исчезло» (или наоборот), вечные ретраи/тишина. | низк. | низк. | Явные `_DELETE_GONE_MARKERS` → True; всё прочее (включая 5xx) → False; повтор delete на следующем апдейте безопасен (идемпотентно). | AC-12; TC-13, TC-14 |
|
||||
| R-6 | **Падение CI на старых ассертах** — тесты `tests/test_telegram_tracker.py` проверяют EN-метки/«Ревью БРД». | сред. | сред. | ТЗ §5 явно требует обновить существующие ассерты под русские метки и «Подтверждение BRD» в том же PR. | AC-20; TC-18, TC-21, TC-24 |
|
||||
| R-7 | **Сломанная human-gate индикация** — ✅ показан до прохождения approve-gate (ввод в заблуждение). | низк. | низк. | ✅ только при заданном `brd_review_ended_at`; ветка ожидания (`review_seconds is None`, ⏳) не меняется. | AC-16; TC-19, TC-20 |
|
||||
| R-8 | **Скрытая зависимость от display-label** — русификация `_TRACKER_STAGES` ломает логику, завязанную на текст метки. | низк. | сред. | Меняется только 2-й элемент кортежа (label); ключи стадий и имена агентов (`_STAGE_ACTIVE_AGENT`, `last_done`, БД) не трогаются. | AC-17; TC-21 |
|
||||
| R-9 | **Self-hosting: прод-сбой при выкатке self** — общая БД/очередь, рестарт орка останавливает все проекты. | низк. | выс. | Изменения только в коде нотификаций, миграций БД нет; обязательная страховка `deploy-staging` (8501) перед prod (CLAUDE.md, INFRA.md, adr-0003). Дефолт edit → даже при выкатке поведение не меняется без явного флага. | стадия deploy-staging; `check_staging_status` |
|
||||
| R-10 | **Документация не обновлена** в том же PR (internals.md / .env.example / CHANGELOG) → REQUEST_CHANGES. | сред. | низк. | ТЗ §5 и AC-21 фиксируют список; reviewer проверяет наличие. | AC-21 |
|
||||
|
||||
## Сводный вывод
|
||||
Все риски — **низкие по вероятности** при соблюдении инвариантов из ADR-001 (edit-дефолт, ≤1 send/вызов, repoint-only-on-success, never-raises, правка только display-label). Остаточный групповой self-hosting-риск (R-9) полностью покрывается обязательным `deploy-staging`-гейтом и тем, что дефолтное поведение не меняется. Блокеров для перехода на стадию development нет.
|
||||
56
docs/work-items/ORCH-042/12-review.md
Normal file
56
docs/work-items/ORCH-042/12-review.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-042
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-042
|
||||
|
||||
## Summary
|
||||
Telegram live-tracker: добавлен режим `bump` (`ORCH_TRACKER_MODE` / `Settings.tracker_mode`, дефолт `edit`) + русификация и косметика карточки. Реализация точно соответствует `02-trz.md` и `06-adr/ADR-001-tracker-bump-mode.md`. Все 21 критерий приёмки покрыты; `pytest tests/ -q` — **494 passed**. Документация обновлена в том же PR. Замечаний уровня P0/P1/P2 нет.
|
||||
|
||||
## Проверка по осям
|
||||
|
||||
### 1. Соответствие ТЗ
|
||||
- `Settings.tracker_mode = "edit"` + env `ORCH_TRACKER_MODE` — есть (config.py).
|
||||
- `delete_telegram(message_id) -> bool` — контракт «never raises», `_DELETE_GONE_MARKERS` вынесены в константу, классификация ok/gone/transient/no-creds реализована дословно по ТЗ §3.1.
|
||||
- Ветвление `update_task_tracker`: bump = delete(best-effort) → send(silent) → repoint только при `new_mid is not None`; edit-ветка сохранена без изменений (§3.2). Инварианты bump (≤1 send/вызов, анти-затирание указателя, delete не блокирует send, всегда тихо) соблюдены.
|
||||
- Текстовые правки BR-9..BR-12 (`_BRD_LABEL`→«Подтверждение BRD», ✅ вместо ⏸️ после approve-gate, русские display-labels `_TRACKER_STAGES`, `_done_link`→«Внедрено») — на месте; ключи стадий и имена агентов не тронуты.
|
||||
- БД, API, сигнатуры helpers, зависимости — без изменений (как и требовалось).
|
||||
|
||||
### 2. Соответствие ADR (ADR-001)
|
||||
Реализация соответствует решениям Р-1..Р-6: флаг с дефолтом edit (нулевая регрессия), delete+send+repoint, best-effort delete, repoint только при успешном send, всегда тихий bump, текст в одной точке. Выбран порядок delete-then-send (A3 отклонён обоснованно). Глобальные ADR не нарушены; решение локально для компонента notifications, что зафиксировано в ADR.
|
||||
|
||||
### 3. Качество кода
|
||||
- Defensive-контракты «never raises» соблюдены и в helper, и в `update_task_tracker`.
|
||||
- Docstrings содержательные; логирование (`debug`/`warning`) корректно разнесено по случаям.
|
||||
- Security/утечек нет; новых зависимостей нет.
|
||||
|
||||
### 4. Качество тестов
|
||||
- `tests/test_config.py` (AC-1), `tests/test_tracker_bump.py` (AC-7..AC-14: ордеринг delete→send, delete-fail, send=None, ≤1 send, классификация delete_telegram, never-raises), `tests/test_telegram_tracker.py` (AC-2 garbage→edit, AC-15..AC-18 русификация, регрессия edit).
|
||||
- Существующие англоязычные ассерты обновлены под русские метки и «Подтверждение BRD» (AC-20).
|
||||
- Тесты содержательные, не тривиальные. `pytest tests/ -q` → 494 passed.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- нет
|
||||
|
||||
### P1 — Must fix
|
||||
- нет
|
||||
|
||||
### P2 — Should fix
|
||||
- нет
|
||||
|
||||
### P3 — Nice to have
|
||||
- [ ] В `_TRACKER_STAGES` строчные комментарии-дубли (`# Анализ` и т.п.) после уже русских меток избыточны — косметика, на поведение не влияет.
|
||||
|
||||
## Документация
|
||||
Обновлена в том же PR, полностью соответствует AC-21:
|
||||
- `CHANGELOG.md` — записи в `[Unreleased] / Added` (bump-режим + `delete_telegram`) и `Changed` (русификация). ✅
|
||||
- `docs/architecture/internals.md` — новая секция «Live Telegram tracker»: режимы edit/bump (таблица), `ORCH_TRACKER_MODE`, контракт `delete_telegram`, текстовые правки. ✅
|
||||
- `.env.example` — `ORCH_TRACKER_MODE=edit` с комментарием. ✅
|
||||
- ADR заведён: `06-adr/ADR-001-tracker-bump-mode.md`. ✅
|
||||
|
||||
Изменения `src/` (config.py, notifications.py) полностью отражены в документации — правило «документация = golden source» выполнено.
|
||||
78
docs/work-items/ORCH-042/13-test-report.md
Normal file
78
docs/work-items/ORCH-042/13-test-report.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-042
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-042
|
||||
|
||||
Telegram live-tracker: режим `bump` (delete+send+repoint, тихо, fallback, never-raises),
|
||||
сохранение режима `edit` без регрессий, русификация карточки.
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Ветка: feature/ORCH-042-telegram-live-tracker-bump
|
||||
- Дата: 2026-06-06
|
||||
- Prod orchestrator (8500): `/health` → `{"status":"ok"}`, активна задача #40 (ORCH-042, stage=testing)
|
||||
|
||||
## Smoke test API
|
||||
| Endpoint | Результат |
|
||||
|----------|-----------|
|
||||
| GET /health | PASS — `{"status":"ok","service":"orchestrator"}` |
|
||||
| GET /status | PASS — активная задача ORCH-042 (stage=testing) |
|
||||
| GET /queue | PASS — queued:0 running:1 done:99 failed:0, breaker=closed |
|
||||
|
||||
(`curl` в окружении недоступен — smoke выполнен через `urllib`.)
|
||||
|
||||
## Результаты по тест-плану (04-test-plan.yaml)
|
||||
|
||||
| TC ID | Описание | AC | Результат |
|
||||
|-------|----------|----|-----------|
|
||||
| TC-01 | Settings.tracker_mode дефолт 'edit', читается из ORCH_TRACKER_MODE | AC-1 | PASS |
|
||||
| TC-02 | Мусорное/пустое значение → edit-ветка, без исключений | AC-2 | PASS |
|
||||
| TC-03 | edit: первый вызов → send тихо, id сохранён, edit не вызван | AC-3 | PASS |
|
||||
| TC-04 | edit: повтор → editMessageText на сохранённый id, нового send нет | AC-4 | PASS |
|
||||
| TC-05 | edit: EDIT_GONE → отправка нового, id обновлён | AC-5 | PASS |
|
||||
| TC-06 | edit: EDIT_NOT_MODIFIED/EDIT_FAILED → нового нет, id не меняется | AC-6 | PASS |
|
||||
| TC-07 | bump: первый вызов → delete не вызван, send тихий, id сохранён | AC-7,9 | PASS |
|
||||
| TC-08 | bump: повтор → delete(старый)→send(тихо)→repoint, порядок соблюдён | AC-8,9,10 | PASS |
|
||||
| TC-09 | bump fallback: delete→False → новое всё равно отправлено | AC-11 | PASS |
|
||||
| TC-10 | bump: send=None → id не затёрт, ≤1 send | AC-13 | PASS |
|
||||
| TC-11 | bump: одна карточка за вызов (send ≤1) | AC-10 | PASS |
|
||||
| TC-12 | delete_telegram: ok:true → True | AC-12 | PASS |
|
||||
| TC-13 | delete_telegram: gone-маркеры → True | AC-12 | PASS |
|
||||
| TC-14 | delete_telegram: неизвестный ok:false / 5xx → False | AC-12 | PASS |
|
||||
| TC-15 | delete_telegram: исключение → False, never raises | AC-12,14 | PASS |
|
||||
| TC-16 | delete_telegram: нет кредов → False, HTTP не вызван | AC-12 | PASS |
|
||||
| TC-17 | update_task_tracker never raises (оба режима) | AC-14 | PASS |
|
||||
| TC-18 | render: «Подтверждение BRD» есть, «Ревью БРД» нет | AC-15 | PASS |
|
||||
| TC-19 | render: approve-gate пройден → строка BRD с ✅ | AC-16 | PASS |
|
||||
| TC-20 | render: ожидание человека → ⏳, не ✅ | AC-16 | PASS |
|
||||
| TC-21 | render: русские метки стадий, английских нет | AC-17 | PASS |
|
||||
| TC-22 | render done: «📦 Внедрено», не «deployed» | AC-18 | PASS |
|
||||
| TC-23 | состав отдельных пингов не изменён | AC-19 | PASS |
|
||||
| TC-24 | вся pytest-сюита зелёная | AC-20 | PASS |
|
||||
|
||||
Все 24 тест-кейса плана покрыты и пройдены. Критерии AC-1..AC-20 подтверждены
|
||||
тестами; AC-21 (документация) подтверждён на ревью (12-review.md, verdict APPROVED).
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
Целевые модули ORCH-042:
|
||||
```
|
||||
tests/test_config.py tests/test_telegram_tracker.py tests/test_tracker_bump.py
|
||||
52 passed, 1 warning in 1.38s
|
||||
```
|
||||
|
||||
Полный регресс:
|
||||
```
|
||||
======================== 494 passed, 1 warning in 8.57s ========================
|
||||
```
|
||||
|
||||
(Единственный warning — PydanticDeprecatedSince20 в `src/config.py:4`, не связан с
|
||||
ORCH-042, существовал ранее, на результат не влияет.)
|
||||
|
||||
## Итог
|
||||
**PASS** — полный регресс 494/494 зелёный, целевые модули 52/52 PASS, smoke API OK.
|
||||
Задача готова к стадии deploy-staging.
|
||||
82
docs/work-items/ORCH-042/14-deploy-log.md
Normal file
82
docs/work-items/ORCH-042/14-deploy-log.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
timestamp: 2026-06-06T10:20:38Z
|
||||
work_item: ORCH-042
|
||||
branch: feature/ORCH-042-telegram-live-tracker-bump
|
||||
commit: 753eea37fc9b0b7bffd9f896ae8149f5a515fc26
|
||||
target_service: orchestrator
|
||||
target_port: 8500
|
||||
deploy_mode: artifact-only
|
||||
staging_gate: SUCCESS
|
||||
prod_container_restarted: false
|
||||
rebuild_required: true
|
||||
---
|
||||
|
||||
# Deploy Log — ORCH-042
|
||||
|
||||
## Verdict
|
||||
|
||||
**`deploy_status: SUCCESS`** — артефактный (artifact-only) деплой-вердикт.
|
||||
|
||||
Реальный `git pull` + `docker compose ... --build` + рестарт прод-контейнера
|
||||
`orchestrator` (8500) в рамках этой стадии **НЕ выполняется**. Он делегирован
|
||||
хуку `scripts/orchestrator-deploy-hook.sh` (ORCH-36), который запускается
|
||||
Владельцем **после** мерджа ветки `feature/ORCH-042-telegram-live-tracker-bump`
|
||||
в `main`. Guardrail: агент никогда не перезапускает общий прод-инстанс внутри
|
||||
ORCH-задачи — это self-hosting групповой риск (CLAUDE.md / INFRA.md
|
||||
§Self-hosting): рестарт прод-орка остановил бы конвейер ВСЕХ проектов.
|
||||
|
||||
## Pre-conditions (все ✓)
|
||||
|
||||
| Артефакт | Поле | Значение |
|
||||
|----------|------|----------|
|
||||
| `12-review.md` | `verdict` | `APPROVED` |
|
||||
| `13-test-report.md` | `result` | `PASS` |
|
||||
| `15-staging-log.md` | `staging_status` | `SUCCESS` (10/10 staging-checks, прогон внутри `orchestrator-staging` :8501) |
|
||||
| `04-test-plan.yaml` | — | покрывает AC задачи |
|
||||
| ADR | `06-adr/ADR-001-tracker-bump-mode.md` | заведён |
|
||||
| `CHANGELOG.md` | — | обновлён |
|
||||
|
||||
Стадия `deploy` достижима только потому, что условный staging-гейт
|
||||
(`check_staging_status`, реальный для self-hosting repo=orchestrator) — зелёный.
|
||||
|
||||
## Change scope — почему нужен rebuild+restart (но не сейчас)
|
||||
|
||||
ORCH-042 меняет **рантайм-код `src/`**, который копируется в образ (`/app/src`)
|
||||
и исполняется прод-процессом — значит для вступления в силу на проде нужен
|
||||
rebuild + restart контейнера:
|
||||
|
||||
| Файл | Тип | Как доезжает до прода |
|
||||
|------|-----|------------------------|
|
||||
| `src/notifications.py` | runtime (в образе) | требует **rebuild + restart** контейнера |
|
||||
| `src/config.py` | runtime (в образе) | требует **rebuild + restart** контейнера |
|
||||
| `.env.example` | дескриптор | реальные значения — в `.env` на хосте (не в гит) |
|
||||
| `docs/**`, `CHANGELOG.md` | docs | мерж в `main` |
|
||||
| `tests/**` | тесты, не деплоятся | n/a |
|
||||
|
||||
`rebuild_required: true`. Изменения добавляют режим **bump** live-tracker'а
|
||||
Telegram (карточка перемещается вниз при обновлении) + русификацию текста
|
||||
уведомлений; они активируются новыми env-флагами (см. `.env.example`).
|
||||
Чтобы новое поведение вступило в силу на проде, прод-инстанс `orchestrator`
|
||||
(8500) должен быть **пересобран и перезапущен Владельцем через деплой-хук
|
||||
после мерджа** — не данным агентом.
|
||||
|
||||
## Deploy-хук (выполняет Владелец после мерджа в main)
|
||||
|
||||
```bash
|
||||
# на хосте mva154, прод-таргет (порт 8500, profile отсутствует → default)
|
||||
TARGET_SERVICE=orchestrator \
|
||||
TARGET_PORT=8500 \
|
||||
TARGET_IMAGE=orchestrator-orchestrator \
|
||||
COMPOSE_PROFILE= \
|
||||
scripts/orchestrator-deploy-hook.sh --deploy
|
||||
```
|
||||
|
||||
Хук: снимает снапшот текущего образа → `git pull origin main` → перезапуск
|
||||
сервиса → health-check (10×6s, до 60s по `GET /health`) → при провале
|
||||
**авто-rollback** на предыдущий образ. Прод-env-флаги bump-режима выставляются
|
||||
в `.env` на хосте до перезапуска.
|
||||
|
||||
> ⚠️ Self-hosting: rebuild прод-орка = групповой риск (общая БД + очередь с
|
||||
> enduro-trails). Деплой проводить в окно низкой активности конвейера;
|
||||
> страховка — авто-rollback хука и зелёный staging-гейт (8501).
|
||||
58
docs/work-items/ORCH-042/15-staging-log.md
Normal file
58
docs/work-items/ORCH-042/15-staging-log.md
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-06T10:19:10+00:00
|
||||
base_url: http://localhost:8501
|
||||
work_item: ORCH-042
|
||||
mode: stub
|
||||
checks: 10/10 PASS
|
||||
---
|
||||
|
||||
# Staging Gate Log — ORCH-042
|
||||
|
||||
Staging test suite completed against the live staging environment
|
||||
(`orchestrator-staging`, port 8501). All checks passed.
|
||||
|
||||
## Execution
|
||||
|
||||
Canonical procedure (ORCH-048, ADR-001): run **inside** the
|
||||
`orchestrator-staging` container so the B6 registry-isolation check reads the
|
||||
registry from the running instance's own process-env (`.env.staging`).
|
||||
|
||||
```
|
||||
docker exec orchestrator-staging \
|
||||
python3 /repos/orchestrator/scripts/staging_check.py \
|
||||
--base-url http://localhost:8501 --mode stub
|
||||
```
|
||||
|
||||
(Executed via the Docker Engine API over the mounted unix socket, since no
|
||||
docker CLI is present in the agent environment; equivalent to the canonical
|
||||
`docker exec`.)
|
||||
|
||||
**Exit code: 0 → staging_status: SUCCESS**
|
||||
|
||||
## Results — 10/10 PASS
|
||||
|
||||
### Block A — SMOKE
|
||||
- ✓ A1 GET /health → 200 status=ok
|
||||
- ✓ A2 GET /queue → 200 with counts/max_concurrency/resilience
|
||||
- ✓ A3 ORCH_STAGING=true (not prod)
|
||||
|
||||
### Block B — ACCESS
|
||||
- ✓ B4 Plane: sandbox project accessible (5 projects, sandbox=YES)
|
||||
- ✓ B5 Gitea: orchestrator-sandbox accessible, push=true
|
||||
- ✓ B6 Registry: sandbox present, prod ET/ORCH absent (isolation confirmed)
|
||||
|
||||
### Block C — E2E (mode=stub)
|
||||
- ✓ C7 Create issue in Plane SANDBOX (HTTP 201)
|
||||
- ✓ C8 Trigger pipeline via /webhook/plane (HTTP 200, HMAC)
|
||||
- ✓ C9a Branch appears in orchestrator-sandbox
|
||||
- ✓ C9b Analyst job enqueued in staging queue
|
||||
|
||||
### Cleanup
|
||||
- ✓ Branch deleted, Plane issue deleted, staging DB job/task rows removed.
|
||||
|
||||
```
|
||||
============================================================
|
||||
RESULT: 10/10 checks PASS
|
||||
============================================================
|
||||
```
|
||||
90
docs/work-items/ORCH-044/14-deploy-log.md
Normal file
90
docs/work-items/ORCH-044/14-deploy-log.md
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
timestamp: 2026-06-06T08:44:04Z
|
||||
work_item: ORCH-044
|
||||
branch: feature/ORCH-044-preflight-auth-effort
|
||||
commit: 08ace892bbf1809a65c1dc504459d052bfd71f79
|
||||
target_service: orchestrator
|
||||
target_port: 8500
|
||||
deploy_mode: artifact-only
|
||||
staging_gate: SUCCESS
|
||||
prod_container_restarted: false
|
||||
rebuild_required: true
|
||||
---
|
||||
|
||||
# Deploy Log — ORCH-044
|
||||
|
||||
## Verdict
|
||||
|
||||
**`deploy_status: SUCCESS`** — артефактный (artifact-only) деплой-вердикт.
|
||||
|
||||
Реальный `git pull` + `docker compose ... --build` + рестарт прод-контейнера
|
||||
`orchestrator` (8500) в рамках этой стадии **НЕ выполняется**. Он делегирован
|
||||
хуку `scripts/orchestrator-deploy-hook.sh` (ORCH-36), который запускается
|
||||
Владельцем **после** мерджа ветки `feature/ORCH-044-preflight-auth-effort` в
|
||||
`main`. Guardrail: агент никогда не перезапускает общий прод-инстанс внутри
|
||||
ORCH-задачи (CLAUDE.md / INFRA.md §Self-hosting).
|
||||
|
||||
## Pre-conditions (все ✓)
|
||||
|
||||
| Артефакт | Поле | Значение |
|
||||
|----------|------|----------|
|
||||
| `12-review.md` | `verdict` | `APPROVED` |
|
||||
| `13-test-report.md` | `result` | `PASS` |
|
||||
| `15-staging-log.md` (origin/main) | `staging_status` | `SUCCESS` (10/10 staging-checks, прогон внутри `orchestrator-staging` :8501) |
|
||||
| `04-test-plan.yaml` | — | покрывает AC (P2/`--effort` исключён владельцем → ORCH-50, N/A) |
|
||||
| ADR | `06-adr/ADR-001-preflight-auth-and-empty-result-failure.md` | заведён |
|
||||
| `CHANGELOG.md` | — | обновлён |
|
||||
|
||||
Стадия `deploy` достижима только потому, что условный staging-гейт
|
||||
(`check_staging_status`, реальный для self-hosting repo=orchestrator) — зелёный.
|
||||
|
||||
## Change scope — почему нужен rebuild+restart (но не сейчас)
|
||||
|
||||
В отличие от чисто bind-mount изменений (ср. ORCH-048), ORCH-044 меняет
|
||||
**рантайм-код `src/`**, который копируется в образ (`/app/src`) и исполняется
|
||||
прод-процессом:
|
||||
|
||||
| Файл | Тип | Как доезжает до прода |
|
||||
|------|-----|------------------------|
|
||||
| `src/preflight.py` | runtime (в образе) | требует **rebuild + restart** контейнера |
|
||||
| `src/agents/launcher.py` | runtime (в образе) | требует **rebuild + restart** контейнера |
|
||||
| `src/config.py` | runtime (в образе) | требует **rebuild + restart** контейнера |
|
||||
| `docs/**`, `CHANGELOG.md` | docs | мерж в `main` |
|
||||
| `tests/**` | тесты, не деплоятся | n/a |
|
||||
|
||||
`rebuild_required: true`. Чтобы новый token-free auth-гейт preflight и
|
||||
«пустой лог ⇒ провал» вступили в силу на проде, прод-инстанс `orchestrator`
|
||||
(8500) должен быть пересобран и перезапущен. **Это делает Владелец через
|
||||
деплой-хук после мерджа**, не данный агент.
|
||||
|
||||
## Self-hosting policy
|
||||
|
||||
> ORCH-044 правит слой запуска агента (preflight/launcher/config) того самого
|
||||
> инструмента, который СЕЙЧАС обслуживает все прод-проекты (orchestrator +
|
||||
> enduro-trails) из одного инстанса `orchestrator:8500` с общей БД и общей
|
||||
> очередью.
|
||||
|
||||
Поэтому в рамках этой стадии:
|
||||
- **Прод-контейнер `orchestrator` (8500) НЕ трогался** — ни рестарта, ни
|
||||
пересборки (групповой риск для всех проектов).
|
||||
- **Деплой-хук** `scripts/orchestrator-deploy-hook.sh` (реальный docker/SSH)
|
||||
**не запускался** этим агентом (не было явной инструкции Owner; зарезервирован
|
||||
за ним, ORCH-36). У хука есть health-цикл (10×6с) + авто-rollback —
|
||||
страховка на момент боевого rebuild+restart.
|
||||
- **Страховка пройдена:** staging (8501, изолированная БД/реестр) — зелёный
|
||||
перед прод-деплоем (ORCH-35).
|
||||
|
||||
## Deploy action
|
||||
|
||||
- **Prod rebuild/restart:** требуется (`src/` изменён), **не выполнен** этим
|
||||
агентом (guardrail self-hosting). Выполняется Владельцем через деплой-хук
|
||||
после мерджа в `main`.
|
||||
- **Эффективный rollout:** мерж ветки в `main` → Owner запускает
|
||||
`scripts/orchestrator-deploy-hook.sh` (прод-режим: `TARGET_SERVICE=orchestrator
|
||||
TARGET_PORT=8500 COMPOSE_PROFILE=""`) с health-check + авто-rollback.
|
||||
|
||||
## Verdict
|
||||
|
||||
`deploy_status: SUCCESS` — все гейты зелёные, артефакт-вердикт зафиксирован,
|
||||
боевой rebuild+restart делегирован Owner-хуку. Прод-инстанс не затронут.
|
||||
@@ -134,6 +134,14 @@ class Settings(BaseSettings):
|
||||
telegram_bot_token: str = ""
|
||||
telegram_chat_id: str = ""
|
||||
|
||||
# ORCH-042: режим live-трекера задачи.
|
||||
# edit -> карточка редактируется на месте (editMessageText), ДЕФОЛТ (как было).
|
||||
# bump -> при обновлении старое сообщение удаляется и карточка отправляется
|
||||
# заново вниз чата (deleteMessage + sendMessage + repoint message_id),
|
||||
# тихо (disable_notification). Одна карточка на задачу в обоих режимах.
|
||||
# Неизвестное/пустое значение трактуется как edit (см. notifications).
|
||||
tracker_mode: str = "edit"
|
||||
|
||||
class Config:
|
||||
env_prefix = "ORCH_"
|
||||
env_file = ".env"
|
||||
|
||||
@@ -68,6 +68,62 @@ def send_telegram(text: str, disable_notification: bool = False):
|
||||
return None
|
||||
|
||||
|
||||
# Telegram error descriptions that mean a deleteMessage target is already gone /
|
||||
# can't be deleted (>48h, already deleted, invalid id). Treated as "no longer our
|
||||
# problem" -> the caller proceeds to send a fresh card. NOT a transient failure.
|
||||
_DELETE_GONE_MARKERS = (
|
||||
"message to delete not found",
|
||||
"message can't be deleted",
|
||||
"message_id_invalid",
|
||||
)
|
||||
|
||||
|
||||
def delete_telegram(message_id: int) -> bool:
|
||||
"""Delete a Telegram message. Never raises.
|
||||
|
||||
Returns True if the message is gone after the call (deleted now, OR Telegram
|
||||
says it's already not there / can't be deleted -> treat as "no longer our
|
||||
problem", caller proceeds to send a fresh card). Returns False only on a
|
||||
transient failure (network / timeout / 5xx / unknown error) where the old
|
||||
message may still be alive.
|
||||
"""
|
||||
s = _get_settings()
|
||||
if not s.telegram_bot_token or not s.telegram_chat_id:
|
||||
# No creds -> nothing was deleted; mirror the other helpers' no-op path.
|
||||
return False
|
||||
try:
|
||||
url = f"https://api.telegram.org/bot{s.telegram_bot_token}/deleteMessage"
|
||||
resp = httpx.post(
|
||||
url,
|
||||
json={
|
||||
"chat_id": s.telegram_chat_id,
|
||||
"message_id": message_id,
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("ok"):
|
||||
return True
|
||||
# ok:false -> classify. "Already gone / can't delete" is an expected,
|
||||
# non-transient outcome (>48h, already deleted) -> the old message is no
|
||||
# longer there, caller should still send a fresh card.
|
||||
desc = str(data.get("description") or "").lower()
|
||||
if any(m in desc for m in _DELETE_GONE_MARKERS):
|
||||
logger.debug(
|
||||
f"delete_telegram(mid={message_id}): already gone ({desc!r})"
|
||||
)
|
||||
return True
|
||||
# Unknown 400 / 5xx -> transient; the old message may still be alive.
|
||||
logger.warning(
|
||||
f"delete_telegram(mid={message_id}): delete failed ({desc!r})"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
# Network / timeout -> transient; old message may still be alive.
|
||||
logger.warning(f"delete_telegram(mid={message_id}): transient error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# edit_telegram outcome codes -> let update_task_tracker decide what to do:
|
||||
# "ok" edit applied -> nothing else to do
|
||||
# "not_modified" Telegram says text is identical (400 "message is not
|
||||
@@ -166,19 +222,23 @@ def _get_work_item_id(task_id: int) -> str:
|
||||
# the agent whose agent_runs rows describe that stage's work. "Ревью БРД" is NOT
|
||||
# an agent stage — it is the human approve gate rendered between Analysis and
|
||||
# Architecture from the task's brd_review_* timestamps.
|
||||
# ORCH-042 (BR-11): display-labels are Russian. Stage KEYS (analysis, …) and
|
||||
# agent names (analyst, …) are NOT touched — they are wired to
|
||||
# _STAGE_ACTIVE_AGENT, last_done and the DB. Only the 2nd tuple element changed.
|
||||
_TRACKER_STAGES = [
|
||||
("analysis", "Analysis", "analyst"),
|
||||
("architecture", "Architecture", "architect"),
|
||||
("development", "Development", "developer"),
|
||||
("review", "Review", "reviewer"),
|
||||
("testing", "Testing", "tester"),
|
||||
("deploy", "Deploy", "deployer"),
|
||||
("analysis", "Анализ", "analyst"), # Анализ
|
||||
("architecture", "Архитектура", "architect"), # Архитектура
|
||||
("development", "Разработка", "developer"), # Разработка
|
||||
("review", "Код ревью", "reviewer"), # Код ревью
|
||||
("testing", "Тестирование", "tester"), # Тестирование
|
||||
("deploy", "Внедрение", "deployer"), # Внедрение
|
||||
]
|
||||
|
||||
# Map a pipeline stage -> the agent that is RUNNING while the task sits in it.
|
||||
# (development is entered after architecture finishes, etc.) Used to render the
|
||||
# "🔄 <Stage> … идёт" line for the currently-active stage.
|
||||
_BRD_LABEL = "\u0420\u0435\u0432\u044c\u044e \u0411\u0420\u0414" # "Ревью БРД"
|
||||
# ORCH-042 (BR-9): "Подтверждение BRD" (was "Ревью БРД").
|
||||
_BRD_LABEL = "Подтверждение BRD"
|
||||
|
||||
_STAGE_ACTIVE_AGENT = {
|
||||
"analysis": "analyst",
|
||||
@@ -232,7 +292,8 @@ def render_task_tracker(task_id: int) -> str:
|
||||
the BRD-review timestamps, then renders:
|
||||
- one '✅ <Stage> <dur> · <in>↓/<out>↑ · <cost> · <model>' line per finished
|
||||
stage (latest run per stage),
|
||||
- the '⏸️ Ревью БРД <dur> · твоё время[ ⏳]' line between Analysis/Architecture,
|
||||
- the '✅/⏸️ Подтверждение BRD <dur> · твоё время[ ⏳]' line between
|
||||
Analysis/Architecture (✅ once the approve-gate passed, ⏸️+⏳ while waiting),
|
||||
- a '🔄 <Stage> … идёт' line for the active (in-progress) stage,
|
||||
- the '💰 <in>↓ / <out>↑ · <cost>' totals,
|
||||
- on done: '⏱️ Всего .. · агенты .. · твоё ..' and a '🔗 PR / 📦' line.
|
||||
@@ -365,9 +426,11 @@ def render_task_tracker(task_id: int) -> str:
|
||||
if stage_key == "analysis" and brd_started:
|
||||
brd_label = f"{_BRD_LABEL:<13}"
|
||||
if review_seconds is not None:
|
||||
# ORCH-042 (BR-10): approve-gate passed -> \u2705 (was \u23f8\ufe0f). The
|
||||
# still-waiting branch below keeps \u23f8\ufe0f + \u23f3 unchanged.
|
||||
dur = _fmt_minutes(review_seconds)
|
||||
lines.append(
|
||||
f"\u23f8\ufe0f {brd_label} {dur} \u00b7 \u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f"
|
||||
f"\u2705 {brd_label} {dur} \u00b7 \u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f"
|
||||
)
|
||||
else:
|
||||
# Still waiting on the human (ended not stamped yet).
|
||||
@@ -406,7 +469,7 @@ def render_task_tracker(task_id: int) -> str:
|
||||
|
||||
|
||||
def _done_link(task_id: int, work_item_id) -> str | None:
|
||||
"""Build the final '🔗 PR #n · 📦 deployed' line. Never raises -> None."""
|
||||
"""Build the final '🔗 PR #n · 📦 Внедрено' line. Never raises -> None."""
|
||||
try:
|
||||
from .config import settings
|
||||
from .db import get_db
|
||||
@@ -436,7 +499,7 @@ def _done_link(task_id: int, work_item_id) -> str | None:
|
||||
parts = []
|
||||
if pr_part:
|
||||
parts.append(pr_part)
|
||||
parts.append("\U0001f4e6 deployed")
|
||||
parts.append("\U0001f4e6 Внедрено") # ORCH-042 (BR-12): was "deployed"
|
||||
return " \u00b7 ".join(parts)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -445,19 +508,49 @@ def _done_link(task_id: int, work_item_id) -> str | None:
|
||||
def update_task_tracker(task_id: int):
|
||||
"""Render + push the live tracker for a task. Never raises.
|
||||
|
||||
First call (no stored tracker_message_id): sendMessage (silent) and store the
|
||||
returned message_id. Subsequent calls: editMessageText the stored message.
|
||||
A NEW message is sent ONLY when the original is truly gone (deleted / too old
|
||||
/ invalid id). On "not modified" (text unchanged) or transient failures
|
||||
(network / timeout / 5xx / unknown 400) we do NOT send a new message — that
|
||||
is exactly what produced duplicate trackers and orphaned (lagging) messages.
|
||||
Two modes, selected by Settings.tracker_mode (env ORCH_TRACKER_MODE),
|
||||
resolved case-insensitively here; anything other than "bump" -> "edit"
|
||||
(ORCH-042). Both keep the "one card per task" invariant.
|
||||
|
||||
edit (DEFAULT):
|
||||
First call (no stored tracker_message_id): sendMessage (silent) and store
|
||||
the returned message_id. Subsequent calls: editMessageText the stored
|
||||
message. A NEW message is sent ONLY when the original is truly gone
|
||||
(deleted / too old / invalid id). On "not modified" (text unchanged) or
|
||||
transient failures (network / timeout / 5xx / unknown 400) we do NOT send
|
||||
a new message — that is exactly what produced duplicate trackers and
|
||||
orphaned (lagging) messages.
|
||||
|
||||
bump (ORCH-042):
|
||||
The card is re-created at the BOTTOM of the chat on every update:
|
||||
best-effort delete_telegram(old_id) (its result NEVER blocks the send),
|
||||
then sendMessage (silent), then re-point tracker_message_id to the new id
|
||||
— but ONLY on a successful send (new_mid is not None), so a transient send
|
||||
failure never wipes the pointer to None. At most ONE new message is sent
|
||||
per call -> no duplicates within a call.
|
||||
|
||||
The tracker is always sent with disable_notification so it never pings —
|
||||
only the dedicated alert helpers ping.
|
||||
"""
|
||||
try:
|
||||
from .db import get_tracker_message_id, set_tracker_message_id
|
||||
text = render_task_tracker(task_id)
|
||||
mode = (_get_settings().tracker_mode or "edit").strip().lower()
|
||||
mid = get_tracker_message_id(task_id)
|
||||
|
||||
if mode == "bump":
|
||||
# bump: one card, always at the bottom (delete + send + repoint).
|
||||
if mid is not None:
|
||||
# best-effort; result does NOT gate the send (BR-6).
|
||||
delete_telegram(mid)
|
||||
new_mid = send_telegram(text, disable_notification=True)
|
||||
if new_mid is not None:
|
||||
set_tracker_message_id(task_id, new_mid)
|
||||
# send returned None (no creds / transient) -> leave mid untouched;
|
||||
# no duplicate within this call, redraws on the next transition.
|
||||
return
|
||||
|
||||
# mode == "edit" (DEFAULT): existing behaviour, unchanged.
|
||||
if mid is not None:
|
||||
result = edit_telegram(mid, text)
|
||||
if result in (EDIT_OK, EDIT_NOT_MODIFIED):
|
||||
|
||||
27
tests/test_config.py
Normal file
27
tests/test_config.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""ORCH-042: Settings.tracker_mode config field.
|
||||
|
||||
AC-1: tracker_mode defaults to "edit" and is read from env ORCH_TRACKER_MODE.
|
||||
Settings is a Pydantic BaseSettings reading env at instantiation, so each case
|
||||
builds a FRESH Settings() (the process-wide singleton is not mutated).
|
||||
"""
|
||||
|
||||
from src.config import Settings
|
||||
|
||||
|
||||
def test_tracker_mode_defaults_to_edit(monkeypatch):
|
||||
# No env var -> default "edit" (TC-01 / AC-1).
|
||||
monkeypatch.delenv("ORCH_TRACKER_MODE", raising=False)
|
||||
assert Settings().tracker_mode == "edit"
|
||||
|
||||
|
||||
def test_tracker_mode_reads_env_bump(monkeypatch):
|
||||
# ORCH_TRACKER_MODE=bump -> "bump" (TC-01 / AC-1).
|
||||
monkeypatch.setenv("ORCH_TRACKER_MODE", "bump")
|
||||
assert Settings().tracker_mode == "bump"
|
||||
|
||||
|
||||
def test_tracker_mode_reads_env_arbitrary(monkeypatch):
|
||||
# The field is read verbatim from env; mode RESOLUTION (anything != "bump"
|
||||
# -> edit) happens in notifications, not here (AC-1/AC-2 split).
|
||||
monkeypatch.setenv("ORCH_TRACKER_MODE", "garbage")
|
||||
assert Settings().tracker_mode == "garbage"
|
||||
112
tests/test_orch040_compose.py
Normal file
112
tests/test_orch040_compose.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""ORCH-040: контейнер/агенты бегут под uid:gid хоста (1000:1000), не root.
|
||||
|
||||
Валидируют docker-compose.yml (Вариант 1 из ADR-001) и согласованность с
|
||||
HOME, который форсит launcher. Чистые конфиг-тесты: парсят YAML и текст
|
||||
launcher, без запуска docker/агентов.
|
||||
|
||||
См. docs/work-items/ORCH-040/{02-trz.md,03-acceptance-criteria.md,
|
||||
04-test-plan.yaml} и 06-adr/ADR-001-run-agents-as-host-uid.md.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
COMPOSE_PATH = REPO_ROOT / "docker-compose.yml"
|
||||
LAUNCHER_PATH = REPO_ROOT / "src" / "agents" / "launcher.py"
|
||||
|
||||
# Сервисы, которые исполняют конвейер и обязаны бежать под uid хоста.
|
||||
PIPELINE_SERVICES = ("orchestrator", "orchestrator-staging")
|
||||
|
||||
# Единый HOME агента (форсится launcher'ом); под ним должны лежать .ssh/.claude.
|
||||
EXPECTED_HOME = "/home/slin"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def compose() -> dict:
|
||||
"""Распарсенный docker-compose.yml."""
|
||||
with COMPOSE_PATH.open(encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
assert "services" in data, "docker-compose.yml без секции services"
|
||||
return data
|
||||
|
||||
|
||||
def _service(compose: dict, name: str) -> dict:
|
||||
services = compose["services"]
|
||||
assert name in services, f"сервис {name} отсутствует в docker-compose.yml"
|
||||
return services[name]
|
||||
|
||||
|
||||
def _ssh_mount_target(service: dict) -> str:
|
||||
"""Target SSH-маунта (источник .orchestrator-ssh) для сервиса."""
|
||||
for vol in service.get("volumes", []):
|
||||
# формат "src:target[:mode]"
|
||||
parts = vol.split(":")
|
||||
src = parts[0]
|
||||
if src.endswith(".orchestrator-ssh"):
|
||||
assert len(parts) >= 2, f"SSH-маунт без target: {vol}"
|
||||
return parts[1]
|
||||
raise AssertionError("SSH-маунт (.orchestrator-ssh) не найден в volumes")
|
||||
|
||||
|
||||
# --- TC-01: user: "1000:1000" в обоих сервисах ---------------------------------
|
||||
@pytest.mark.parametrize("name", PIPELINE_SERVICES)
|
||||
def test_tc01_service_runs_as_host_uid(compose, name):
|
||||
"""TC-01/AC-1: сервис бежит под uid:gid хоста 1000:1000, а не root."""
|
||||
service = _service(compose, name)
|
||||
assert "user" in service, f"{name}: отсутствует ключ user (нужен '1000:1000')"
|
||||
# docker допускает int или строку; нормализуем к строке.
|
||||
assert str(service["user"]) == "1000:1000", (
|
||||
f"{name}: user={service['user']!r}, ожидалось '1000:1000'"
|
||||
)
|
||||
|
||||
|
||||
# --- TC-02: group_add сохраняет "999" (docker.sock — МИНА 1) --------------------
|
||||
@pytest.mark.parametrize("name", PIPELINE_SERVICES)
|
||||
def test_tc02_group_add_keeps_docker_gid(compose, name):
|
||||
"""TC-02/AC-4: group_add содержит 999 (доступ к docker.sock не потерян)."""
|
||||
service = _service(compose, name)
|
||||
group_add = service.get("group_add", [])
|
||||
normalized = {str(g) for g in group_add}
|
||||
assert "999" in normalized, (
|
||||
f"{name}: group_add={group_add!r}, должен содержать '999' (docker.sock)"
|
||||
)
|
||||
|
||||
|
||||
# --- TC-03: SSH-маунт согласован с HOME (под /home/slin, не /root) --------------
|
||||
@pytest.mark.parametrize("name", PIPELINE_SERVICES)
|
||||
def test_tc03_ssh_mount_under_home(compose, name):
|
||||
"""TC-03/AC-5: target SSH-маунта лежит в HOME агента (/home/slin/.ssh)."""
|
||||
service = _service(compose, name)
|
||||
target = _ssh_mount_target(service)
|
||||
assert target == f"{EXPECTED_HOME}/.ssh", (
|
||||
f"{name}: SSH target={target!r}, ожидалось '{EXPECTED_HOME}/.ssh' "
|
||||
f"(не /root/.ssh — иначе рассинхрон с HOME агента)"
|
||||
)
|
||||
assert not target.startswith("/root/"), (
|
||||
f"{name}: SSH target указывает на чужой HOME (/root): {target}"
|
||||
)
|
||||
|
||||
|
||||
# --- TC-04: HOME launcher'а совместим с SSH/claude-маунтами ---------------------
|
||||
def test_tc04_launcher_home_matches_mounts(compose):
|
||||
"""TC-04: HOME, форсимый launcher'ом, совпадает с базой SSH/claude-маунтов.
|
||||
|
||||
Нет рассинхрона HOME vs uid: и env Popen, и git_env, и target SSH-маунта
|
||||
все указывают на /home/slin.
|
||||
"""
|
||||
source = LAUNCHER_PATH.read_text(encoding="utf-8")
|
||||
# launcher форсит HOME в двух местах (env Popen и git_env).
|
||||
occurrences = source.count(f'"HOME": "{EXPECTED_HOME}"')
|
||||
assert occurrences >= 2, (
|
||||
f"launcher.py: ожидалось >=2 форсинга HOME={EXPECTED_HOME!r}, "
|
||||
f"найдено {occurrences}"
|
||||
)
|
||||
# И SSH-маунты обоих сервисов ведут в этот же HOME.
|
||||
for name in PIPELINE_SERVICES:
|
||||
target = _ssh_mount_target(_service(compose, name))
|
||||
assert target.startswith(f"{EXPECTED_HOME}/"), (
|
||||
f"{name}: SSH target {target} не под HOME агента {EXPECTED_HOME}"
|
||||
)
|
||||
@@ -3,7 +3,7 @@
|
||||
Covers (per DEV_TASK_TELEGRAM_TRACKER.md):
|
||||
* short_model_name: provider/claude- prefix trimming.
|
||||
* render_task_tracker: per-stage line format (in↓/out↑, model, cost, minutes),
|
||||
the "⏸️ Ревью БРД · твоё время" line, the 💰 totals, and the finish block
|
||||
the "✅/⏸️ Подтверждение BRD · твоё время" line, the 💰 totals, and the finish block
|
||||
(⏱️ three times + 🔗/📦).
|
||||
* first message -> sendMessage stores message_id; transition -> editMessageText.
|
||||
* fallback: editMessageText fails -> a NEW message is sent and the id updated.
|
||||
@@ -134,17 +134,17 @@ def test_render_in_progress_stage_lines_and_totals():
|
||||
# Header in-progress
|
||||
assert text.startswith("\U0001f6e0\ufe0f ET-012 \u00b7 \u0422\u0440\u0435\u043a\u0438")
|
||||
# Per-stage format: in↓/out↑ · cost · model
|
||||
assert "\u2705 Analysis" in text
|
||||
assert "\u2705 Анализ" in text
|
||||
assert "10\u043c" in text # analysis duration
|
||||
assert "39.6k\u2191" in text # analysis out
|
||||
assert "$2.38" in text
|
||||
assert "opus-4-8" in text
|
||||
assert "sonnet-4.6" in text # reviewer/tester model
|
||||
# BRD review line (human time, ended)
|
||||
assert "\u0420\u0435\u0432\u044c\u044e \u0411\u0420\u0414" in text
|
||||
assert "Подтверждение BRD" in text
|
||||
assert "\u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f" in text
|
||||
# Active stage
|
||||
assert "\U0001f504 Deploy" in text
|
||||
assert "\U0001f504 Внедрение" in text
|
||||
assert "\u0438\u0434\u0451\u0442" in text
|
||||
# Totals line present with 💰
|
||||
assert "\U0001f4b0" in text
|
||||
@@ -159,7 +159,7 @@ def test_render_brd_review_waiting_shows_hourglass():
|
||||
in_tok=1000, out_tok=39600, cache_read=1_100_000, cost=2.38,
|
||||
model="tokenator/claude-opus-4-8")
|
||||
text = N.render_task_tracker(tid)
|
||||
assert "\u0420\u0435\u0432\u044c\u044e \u0411\u0420\u0414" in text
|
||||
assert "Подтверждение BRD" in text
|
||||
assert "\u23f3" in text # hourglass while waiting
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ def test_render_omits_model_when_unknown():
|
||||
in_tok=10, out_tok=5, cost=0.0, model=None)
|
||||
text = N.render_task_tracker(tid)
|
||||
# No trailing " · <model>" — line ends at cost.
|
||||
line = [l for l in text.splitlines() if l.startswith("\u2705 Analysis")][0]
|
||||
line = [l for l in text.splitlines() if l.startswith("\u2705 Анализ")][0]
|
||||
assert line.rstrip().endswith("$0.00")
|
||||
|
||||
|
||||
@@ -408,7 +408,7 @@ def test_render_active_stage_shows_attempt_on_second_run():
|
||||
|
||||
text = N.render_task_tracker(tid)
|
||||
active = [l for l in text.splitlines()
|
||||
if l.startswith("\U0001f504") and "Review" in l][0]
|
||||
if l.startswith("\U0001f504") and "Код ревью" in l][0]
|
||||
assert _POPYTKA in active
|
||||
assert "2" in active
|
||||
assert "\u0438\u0434\u0451\u0442" in active
|
||||
@@ -426,7 +426,7 @@ def test_render_active_stage_no_attempt_on_first_run():
|
||||
|
||||
text = N.render_task_tracker(tid)
|
||||
active = [l for l in text.splitlines()
|
||||
if l.startswith("\U0001f504") and "Review" in l][0]
|
||||
if l.startswith("\U0001f504") and "Код ревью" in l][0]
|
||||
assert _POPYTKA not in active
|
||||
assert "\u0438\u0434\u0451\u0442" in active
|
||||
|
||||
@@ -516,3 +516,112 @@ def test_qg_failure_does_not_send_separate_message(monkeypatch):
|
||||
lambda text, disable_notification=False: sent.append(text) or 1)
|
||||
N.notify_qg_failure(tid, "development", "check_ci_green", "CI state: pending")
|
||||
assert sent == [] # QG-pending is log-only, never a separate ping
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ORCH-042: mode resolution + text changes (edit-mode default)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _brd_line(text):
|
||||
return [ln for ln in text.splitlines() if "Подтверждение BRD" in ln][0]
|
||||
|
||||
|
||||
def test_unknown_mode_falls_back_to_edit_branch(monkeypatch):
|
||||
"""TC-02/AC-2: garbage mode -> edit branch, no exception, no extra send."""
|
||||
monkeypatch.setattr(N._get_settings(), "tracker_mode", "garbage", raising=False)
|
||||
tid = _mk_task(stage="development")
|
||||
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
||||
in_tok=10, out_tok=5, cost=0.1)
|
||||
from src.db import set_tracker_message_id, get_tracker_message_id
|
||||
set_tracker_message_id(tid, 777)
|
||||
|
||||
edited = {}
|
||||
monkeypatch.setattr(N, "edit_telegram",
|
||||
lambda mid, text: edited.update(mid=mid) or N.EDIT_OK)
|
||||
monkeypatch.setattr(N, "send_telegram",
|
||||
lambda *a, **k: (_ for _ in ()).throw(
|
||||
AssertionError("garbage mode must take edit branch")))
|
||||
monkeypatch.setattr(N, "delete_telegram",
|
||||
lambda *a, **k: (_ for _ in ()).throw(
|
||||
AssertionError("garbage mode must NOT bump-delete")))
|
||||
|
||||
N.update_task_tracker(tid) # must not raise
|
||||
assert edited["mid"] == 777
|
||||
assert get_tracker_message_id(tid) == 777 # unchanged
|
||||
|
||||
|
||||
def test_render_brd_label_is_confirmation_not_review():
|
||||
"""TC-18/AC-15: 'Подтверждение BRD' present, 'Ревью БРД' absent."""
|
||||
tid = _mk_task(stage="architecture", brd_start="2026-06-04 10:00:00",
|
||||
brd_end="2026-06-04 10:08:00")
|
||||
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
||||
in_tok=10, out_tok=5, cost=0.1)
|
||||
text = N.render_task_tracker(tid)
|
||||
assert "Подтверждение BRD" in text
|
||||
assert "Ревью БРД" not in text
|
||||
|
||||
|
||||
def test_render_brd_passed_uses_check_not_pause():
|
||||
"""TC-19/AC-16: approve-gate passed (ended set) -> BRD line starts with ✅."""
|
||||
tid = _mk_task(stage="architecture", brd_start="2026-06-04 10:00:00",
|
||||
brd_end="2026-06-04 10:08:00")
|
||||
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
||||
in_tok=10, out_tok=5, cost=0.1)
|
||||
line = _brd_line(N.render_task_tracker(tid))
|
||||
assert line.startswith("✅") # ✅
|
||||
assert not line.startswith("⏸") # not ⏸️
|
||||
assert "⏳" not in line # no hourglass once passed
|
||||
|
||||
|
||||
def test_render_brd_waiting_keeps_pause_and_hourglass():
|
||||
"""TC-20/AC-16: still waiting (ended empty) -> ⏳ indicator, not ✅."""
|
||||
tid = _mk_task(stage="analysis", brd_start="2026-06-04 10:00:00",
|
||||
brd_end=None)
|
||||
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
||||
in_tok=10, out_tok=5, cost=0.1)
|
||||
line = _brd_line(N.render_task_tracker(tid))
|
||||
assert "⏳" in line # ⏳ still waiting
|
||||
assert not line.startswith("✅") # NOT ✅ yet
|
||||
|
||||
|
||||
def test_render_stage_labels_are_russian():
|
||||
"""TC-21/AC-17: russian stage labels in both ✅- and 🔄-lines; no english."""
|
||||
tid = _mk_task(stage="deploy")
|
||||
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00",
|
||||
in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8")
|
||||
_mk_run(tid, "architect", "2026-06-04 09:10:00", "2026-06-04 09:20:00",
|
||||
in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8")
|
||||
_mk_run(tid, "developer", "2026-06-04 09:20:00", "2026-06-04 09:30:00",
|
||||
in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8")
|
||||
_mk_run(tid, "reviewer", "2026-06-04 09:30:00", "2026-06-04 09:35:00",
|
||||
in_tok=10, out_tok=5, cost=0.1, model="vibecode/claude-sonnet-4.6")
|
||||
_mk_run(tid, "tester", "2026-06-04 09:35:00", "2026-06-04 09:40:00",
|
||||
in_tok=10, out_tok=5, cost=0.1, model="vibecode/claude-sonnet-4.6")
|
||||
_mk_run(tid, "deployer", "2026-06-04 09:40:00", None,
|
||||
in_tok=0, out_tok=0, exit_code=None)
|
||||
text = N.render_task_tracker(tid)
|
||||
for ru in ("Анализ", "Архитектура", "Разработка", "Код ревью",
|
||||
"Тестирование", "Внедрение"):
|
||||
assert ru in text, f"missing russian label {ru!r}"
|
||||
for en in ("Analysis", "Architecture", "Development", "Review",
|
||||
"Testing", "Deploy"):
|
||||
assert en not in text, f"english label leaked: {en!r}"
|
||||
|
||||
|
||||
def test_render_done_says_vnedreno_not_deployed():
|
||||
"""TC-22/AC-18: final line says '📦 Внедрено', not 'deployed'."""
|
||||
tid = _mk_task(stage="done")
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE tasks SET created_at='2026-06-04 09:00:00', "
|
||||
"updated_at='2026-06-04 09:56:00' WHERE id=?", (tid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
_mk_run(tid, "deployer", "2026-06-04 09:50:00", "2026-06-04 09:56:00",
|
||||
in_tok=400, out_tok=22400, cost=1.73, model="tokenator/claude-opus-4-8")
|
||||
with patch("src.notifications.httpx") as _hx:
|
||||
_resp = MagicMock(status_code=200)
|
||||
_resp.json.return_value = [] # no PR
|
||||
_hx.get.return_value = _resp
|
||||
text = N.render_task_tracker(tid)
|
||||
assert "\U0001f4e6 Внедрено" in text # 📦 Внедрено
|
||||
assert "deployed" not in text
|
||||
|
||||
237
tests/test_tracker_bump.py
Normal file
237
tests/test_tracker_bump.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""ORCH-042: bump-mode live tracker + delete_telegram helper.
|
||||
|
||||
bump = delete(old) + send(new, silent) + repoint message_id. One card per task,
|
||||
always at the bottom. Covers AC-7..AC-14 (TC-07..TC-17). The edit-mode regression
|
||||
stays in tests/test_telegram_tracker.py.
|
||||
|
||||
Isolated temp DB; no network (httpx / low-level helpers are patched per case).
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_tracker_bump.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
|
||||
from unittest.mock import MagicMock, patch # noqa: E402
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
import src.db as db_module # noqa: E402
|
||||
from src.db import ( # noqa: E402
|
||||
init_db, get_db, get_tracker_message_id, set_tracker_message_id,
|
||||
)
|
||||
from src import notifications as N # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(monkeypatch):
|
||||
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
yield
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
|
||||
|
||||
def _mk_task(stage="development", wid="ORCH-042"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
("p1", wid, "orchestrator", "feature/ORCH-042-x", stage, "bump test"),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _bump_mode(monkeypatch):
|
||||
monkeypatch.setattr(N._get_settings(), "tracker_mode", "bump", raising=False)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# bump mode behaviour
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_bump_first_call_sends_silent_no_delete(monkeypatch):
|
||||
"""TC-07/AC-7,AC-9: first call (no id) -> NO delete, silent send, id stored."""
|
||||
_bump_mode(monkeypatch)
|
||||
tid = _mk_task(stage="analysis")
|
||||
|
||||
sends = []
|
||||
monkeypatch.setattr(N, "send_telegram",
|
||||
lambda text, disable_notification=False:
|
||||
sends.append(disable_notification) or 555)
|
||||
monkeypatch.setattr(N, "delete_telegram",
|
||||
lambda mid: (_ for _ in ()).throw(
|
||||
AssertionError("delete must not run on first call")))
|
||||
|
||||
N.update_task_tracker(tid)
|
||||
|
||||
assert sends == [True] # exactly one silent send
|
||||
assert get_tracker_message_id(tid) == 555
|
||||
|
||||
|
||||
def test_bump_repeat_deletes_then_sends_and_repoints(monkeypatch):
|
||||
"""TC-08/AC-8,AC-9,AC-10: repeat -> delete(old) THEN send(silent), id repointed."""
|
||||
_bump_mode(monkeypatch)
|
||||
tid = _mk_task()
|
||||
set_tracker_message_id(tid, 100)
|
||||
|
||||
order = []
|
||||
monkeypatch.setattr(N, "delete_telegram",
|
||||
lambda mid: order.append(("delete", mid)) or True)
|
||||
monkeypatch.setattr(N, "send_telegram",
|
||||
lambda text, disable_notification=False:
|
||||
order.append(("send", disable_notification)) or 200)
|
||||
|
||||
N.update_task_tracker(tid)
|
||||
|
||||
assert order == [("delete", 100), ("send", True)] # delete before send, silent
|
||||
assert get_tracker_message_id(tid) == 200 # repointed to the new card
|
||||
|
||||
|
||||
def test_bump_delete_fail_still_sends(monkeypatch):
|
||||
"""TC-09/AC-11: delete_telegram->False -> new card still sent, id updated."""
|
||||
_bump_mode(monkeypatch)
|
||||
tid = _mk_task()
|
||||
set_tracker_message_id(tid, 100)
|
||||
|
||||
sends = []
|
||||
monkeypatch.setattr(N, "delete_telegram", lambda mid: False) # >48h / transient
|
||||
monkeypatch.setattr(N, "send_telegram",
|
||||
lambda text, disable_notification=False:
|
||||
sends.append(disable_notification) or 201)
|
||||
|
||||
N.update_task_tracker(tid)
|
||||
|
||||
assert sends == [True] # exactly one send despite delete failing
|
||||
assert get_tracker_message_id(tid) == 201
|
||||
|
||||
|
||||
def test_bump_send_none_keeps_old_id(monkeypatch):
|
||||
"""TC-10/AC-13: send->None (transient) -> id NOT wiped, one send attempt."""
|
||||
_bump_mode(monkeypatch)
|
||||
tid = _mk_task()
|
||||
set_tracker_message_id(tid, 100)
|
||||
|
||||
sends = []
|
||||
monkeypatch.setattr(N, "delete_telegram", lambda mid: True)
|
||||
monkeypatch.setattr(N, "send_telegram",
|
||||
lambda text, disable_notification=False:
|
||||
sends.append(1) or None)
|
||||
|
||||
N.update_task_tracker(tid) # must not raise
|
||||
|
||||
assert len(sends) == 1 # exactly one (failed) attempt, no retry/dupe
|
||||
assert get_tracker_message_id(tid) == 100 # pointer preserved, not None
|
||||
|
||||
|
||||
def test_bump_one_card_per_call(monkeypatch):
|
||||
"""TC-11/AC-10: at most one send_telegram per update_task_tracker call."""
|
||||
_bump_mode(monkeypatch)
|
||||
tid = _mk_task()
|
||||
set_tracker_message_id(tid, 100)
|
||||
|
||||
sends = []
|
||||
monkeypatch.setattr(N, "delete_telegram", lambda mid: True)
|
||||
monkeypatch.setattr(N, "send_telegram",
|
||||
lambda text, disable_notification=False:
|
||||
sends.append(1) or 202)
|
||||
|
||||
N.update_task_tracker(tid)
|
||||
assert len(sends) == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# delete_telegram classification (httpx mocked, never raises)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _del_resp(ok, description=None):
|
||||
resp = MagicMock()
|
||||
body = {"ok": ok}
|
||||
if description is not None:
|
||||
body["description"] = description
|
||||
resp.json.return_value = body
|
||||
return resp
|
||||
|
||||
|
||||
def _patch_tg_creds(monkeypatch):
|
||||
monkeypatch.setattr(N._get_settings(), "telegram_bot_token", "T", raising=False)
|
||||
monkeypatch.setattr(N._get_settings(), "telegram_chat_id", "C", raising=False)
|
||||
|
||||
|
||||
def test_delete_ok_true(monkeypatch):
|
||||
"""TC-12: ok:true -> True."""
|
||||
_patch_tg_creds(monkeypatch)
|
||||
with patch("src.notifications.httpx") as hx:
|
||||
hx.post.return_value = _del_resp(True)
|
||||
assert N.delete_telegram(1) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("desc", [
|
||||
"Bad Request: message to delete not found",
|
||||
"Bad Request: message can't be deleted",
|
||||
"Bad Request: MESSAGE_ID_INVALID",
|
||||
])
|
||||
def test_delete_gone_markers_are_true(monkeypatch, desc):
|
||||
"""TC-13/AC-12: 'already gone / can't delete' -> True (not transient)."""
|
||||
_patch_tg_creds(monkeypatch)
|
||||
with patch("src.notifications.httpx") as hx:
|
||||
hx.post.return_value = _del_resp(False, desc)
|
||||
assert N.delete_telegram(1) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("desc", [
|
||||
"Bad Request: some other unexpected error",
|
||||
"Internal Server Error",
|
||||
])
|
||||
def test_delete_unknown_or_5xx_is_false(monkeypatch, desc):
|
||||
"""TC-14/AC-12: unknown ok:false / 5xx -> False (old may still be alive)."""
|
||||
_patch_tg_creds(monkeypatch)
|
||||
with patch("src.notifications.httpx") as hx:
|
||||
hx.post.return_value = _del_resp(False, desc)
|
||||
assert N.delete_telegram(1) is False
|
||||
|
||||
|
||||
def test_delete_exception_is_false(monkeypatch):
|
||||
"""TC-15/AC-12,AC-14: timeout/network -> False, never raises."""
|
||||
_patch_tg_creds(monkeypatch)
|
||||
with patch("src.notifications.httpx") as hx:
|
||||
hx.post.side_effect = Exception("read timeout")
|
||||
assert N.delete_telegram(1) is False
|
||||
|
||||
|
||||
def test_delete_no_creds_is_false_and_no_http(monkeypatch):
|
||||
"""TC-16/AC-12: no token/chat_id -> False, HTTP not called."""
|
||||
monkeypatch.setattr(N._get_settings(), "telegram_bot_token", "", raising=False)
|
||||
monkeypatch.setattr(N._get_settings(), "telegram_chat_id", "", raising=False)
|
||||
with patch("src.notifications.httpx") as hx:
|
||||
assert N.delete_telegram(1) is False
|
||||
hx.post.assert_not_called()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# never raises in either mode
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_update_task_tracker_never_raises_bump(monkeypatch):
|
||||
"""TC-17/AC-14: bump path swallows a render/DB explosion."""
|
||||
_bump_mode(monkeypatch)
|
||||
tid = _mk_task()
|
||||
monkeypatch.setattr(N, "render_task_tracker",
|
||||
lambda task_id: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
# Must not raise.
|
||||
N.update_task_tracker(tid)
|
||||
|
||||
|
||||
def test_update_task_tracker_never_raises_edit(monkeypatch):
|
||||
"""TC-17/AC-14: edit path swallows a render/DB explosion."""
|
||||
monkeypatch.setattr(N._get_settings(), "tracker_mode", "edit", raising=False)
|
||||
tid = _mk_task()
|
||||
monkeypatch.setattr(N, "render_task_tracker",
|
||||
lambda task_id: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
N.update_task_tracker(tid)
|
||||
Reference in New Issue
Block a user