Compare commits
10 Commits
feature/OR
...
feature/OR
| Author | SHA1 | Date | |
|---|---|---|---|
| 577bf8351e | |||
| 08ace892bb | |||
| 2c0745211e | |||
|
|
6fbf7a3f64 | ||
|
|
92fc118e73 | ||
| 98b47fe021 | |||
| 8fb59cd87f | |||
|
|
4488a87404 | ||
| e71a44f84f | |||
| 2f60835536 |
@@ -12,8 +12,3 @@ 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,7 +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`.
|
||||
- **Надёжность запуска агента: preflight ловит авторизацию + пустой результат = провал** (ORCH-044): закрыты две системные дыры, из-за которых разлогиненный/«быстро умерший» агент тихо вешал общую очередь всех проектов (инцидент ORCH-17). **P1 — preflight ловит auth (token-free, без сети/prompt-ping, BR-1):** после успешного `claude --version` (который отвечает даже когда claude разлогинен — версия локальна) `src/preflight.py` читает `<AGENT_HOME>/.claude/.credentials.json` и валидирует OAuth-токен — нет файла / битый JSON / нет `claudeAiOauth.accessToken` ⇒ FAIL; `claudeAiOauth.expiresAt` (epoch ms) `<= now + ORCH_AUTH_EXPIRY_SKEW_SECONDS` ⇒ протух ⇒ FAIL; нет `expiresAt` ⇒ OK (не плодим ложных срабатываний). Путь к credentials резолвится от `AgentLauncher.AGENT_HOME` (`/home/slin`, HOME под которым launcher реально спавнит claude), а не от HOME процесса орка (новый `_agent_home()`, зеркально `_claude_bin()`). Результат кешируется тем же `ORCH_PREFLIGHT_CACHE_TTL`. При `auth=fail` job не клеймится (`_drain_once` уже корректен при `ok=False`), reason виден в `/queue`. Защитная сетка постфактум: `_handle_auth_marker` детектит маркер разлогина в run-логе (`is_auth_failure_text`) и сбрасывает preflight-кеш, чтобы следующий тик переоценил auth (auth-провал НЕ transient, breaker не крутится). Новые настройки: `ORCH_PREFLIGHT_CHECK_AUTH` (тумблер, default true), `ORCH_CLAUDE_CREDENTIALS_PATH` (явный путь), `ORCH_AUTH_EXPIRY_SKEW_SECONDS`. **P3 — пустой лог / нет result-JSON ⇒ провал:** `exit_code==0` больше не считается успехом сам по себе — `_monitor_agent` валидирует результат (`_validate_result`: лог непустой + есть trailing result-JSON по контракту `usage._extract_last_json_object`); `success = exit 0 AND result_ok`. Только при `success` постится «успешный» status-коммент и вызывается `_try_advance_stage`; при `exit 0 & not result_ok` — Telegram-алерт, стадия НЕ двигается, `_finalize_job(result_ok=False)` маршрутизирует job в провал (`empty run log / no result JSON`: по умолчанию permanent → requeue/`failed`+алерт; transient-маркер в логе → transient-путь). Реальный `exit_code` пишется в `agent_runs` без искажения — решение done/fail несёт отдельный флаг `result_ok` (не подменённый код выхода). Итог: `exit 0` всегда завершается терминально/ретраябельно (`done`|`failed`|`queued`) — путь «быстрая смерть с exit 0 → вечный running» закрыт. ⛔ Scope: `--effort` (P2) исключён владельцем и вынесен в ORCH-50 — не трогался. ADR `docs/work-items/ORCH-044/06-adr/ADR-001-preflight-auth-and-empty-result-failure.md`. Тесты: `tests/test_preflight_auth.py`, `tests/test_empty_log_failure.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`.
|
||||
@@ -20,7 +20,6 @@
|
||||
- **Реестр проектов** (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`).
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
- **Stage Engine** (`src/stage_engine.py`) — исполнение переходов, диспетчеризация QG (`_run_qg`), откаты, синхронизация с Plane.
|
||||
- **Review/Test Parsers** (`src/review_parse.py`, ORCH-046) — defensive-извлечение дословного must-fix текста из артефактов для встраивания в `task_desc` заворота: `extract_review_findings` (P0/P1 из `12-review.md`), `extract_test_failures` (фрагмент тела `13-test-report.md`). Контракт «never raise»: любая ошибка → `""`.
|
||||
- **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`.
|
||||
- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance.
|
||||
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe.
|
||||
- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance. **Валидация результата (ORCH-044):** `exit_code==0` считается успехом только если run-лог непустой и содержит валидный result-JSON; пустой/невалидный результат ⇒ job `failed`/retry + алерт, без авто-advance и «успешного» коммента.
|
||||
- **Preflight** (`src/preflight.py`, ORCH-1/ORCH-044) — дешёвый token-free гейт клейма: `os.path.exists(bin)` + `claude --version` + **проверка авторизации** (чтение `<AGENT_HOME>/.claude/.credentials.json` и валидности `claudeAiOauth.expiresAt`; постфактум-маркер `Not logged in`). Кешируется на `preflight_cache_ttl`. Подробнее: [ADR work-item ORCH-044](../work-items/ORCH-044/06-adr/ADR-001-preflight-auth-and-empty-result-failure.md).
|
||||
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe. Не клеймит job при `preflight=fail` (в т.ч. auth-fail).
|
||||
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
|
||||
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane.
|
||||
|
||||
|
||||
@@ -88,7 +88,16 @@ claude.exe --print --system-prompt --allowedTools Read,Write,Edit,Bash
|
||||
1. Записывает run в DB (agent_runs)
|
||||
2. Запускает subprocess. **stdout/stderr перенаправляются СРАЗУ в файл `/app/data/runs/{id}.log` на уровне ОС** (Popen `stdout=log_fh`). Никакого PIPE в памяти оркестратора → нет PIPE-deadlock, нет потока-читателя, нет зомби (B-2).
|
||||
3. Стартует **watchdog thread** (timeout 30 мин → SIGKILL по pid)
|
||||
4. Стартует **monitor thread**: `proc.wait()` (гарантированный reap → реальный exit_code в БД) → закрывает log_fh → git commit/push → auto-advance
|
||||
4. Стартует **monitor thread**: `proc.wait()` (гарантированный reap → реальный exit_code в БД) → закрывает log_fh → **валидация результата (ORCH-044)** → git commit/push → auto-advance
|
||||
|
||||
**Валидация результата (ORCH-044, P3).** `exit_code==0` сам по себе НЕ считается успехом: claude может «быстро умереть» (разлогинен / флаг гасит stdout), оставив пустой или JSON-less лог, но выйдя с кодом 0 — раньше это было неотличимо от успеха (`done` + auto-advance по пустому результату). Теперь `_monitor_agent` вызывает `_validate_result(output_path)`:
|
||||
- лог отсутствует / пустой (0 байт или только whitespace) ⇒ невалиден;
|
||||
- нет парсящегося trailing result-JSON (тот же контракт, что usage-учёт — `usage._extract_last_json_object`) ⇒ невалиден;
|
||||
- хелпер защитный (never-raise); при собственной ошибке — fail-safe в сторону провала.
|
||||
|
||||
`success = (exit_code==0 AND result_ok)`. Реальный `exit_code` пишется в `agent_runs` без искажения; на решение done/fail влияет отдельный флаг `result_ok` (не подменённый код выхода). Только при `success`: постится «успешный» status-коммент и вызывается `_try_advance_stage`. При `exit_code==0 AND not result_ok`: шлётся Telegram-алерт о пустом/невалидном результате, стадия НЕ двигается, а `_finalize_job(result_ok=False)` маршрутизирует job в провал (`empty run log / no result JSON`): по умолчанию permanent (`attempts<max` ⇒ requeue, иначе `failed`+алерт), transient-маркер в логе уводит в transient-путь. Итог: `exit_code==0` всегда завершается терминально/ретраябельно (`done`|`failed`|`queued`) — путь «быстрая смерть с exit 0 → вечный running» закрыт.
|
||||
|
||||
**Постфактум auth-детекция (ORCH-044, P1b).** В пути провала `_handle_auth_marker(log)` ищет маркер разлогина (`not logged in` / `please run /login` / `unauthorized` / `401`) и при совпадении сбрасывает preflight-кеш (`preflight.reset_cache()`), чтобы следующий тик воркера переоценил auth проактивно. Auth-провал НЕ transient и НЕ крутит circuit breaker.
|
||||
|
||||
### 5. Auto-advance (`launcher._try_advance_stage`)
|
||||
|
||||
@@ -107,27 +116,6 @@ 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
|
||||
@@ -237,6 +225,8 @@ services:
|
||||
| Max retries | Developer: max 3 попытки, затем эскалация |
|
||||
| Zombie-free | stdout идёт сразу в файл + monitor `proc.wait()` → процесс всегда reap'нут (B-2) |
|
||||
| Orphan recovery | При старте: orphan-run'ы (finished_at IS NULL, старше 35 мин) помечаются exit=-1 с per-run warning + Telegram-уведомлением «нужна ручная проверка» (M-1) |
|
||||
| Preflight auth-гейт (ORCH-044) | Перед клеймом: `os.path.exists(bin)` + `claude --version` + **token-free auth** (чтение `.credentials.json` + `expiresAt`); разлогинен / протух ⇒ job не клеймится. Постфактум-маркер `not logged in` сбрасывает кеш. Тумблер `ORCH_PREFLIGHT_CHECK_AUTH`. Детали — INFRA.md |
|
||||
| Пустой результат = провал (ORCH-044) | `exit 0` с пустым/JSON-less логом ⇒ `failed`/retry + алерт, без auto-advance (см. §4 «Валидация результата») |
|
||||
|
||||
## Агенты
|
||||
|
||||
@@ -313,12 +303,15 @@ webhook (plane/gitea) background thread (queue_worker)
|
||||
_monitor_agent (proc.wait, commit/push,
|
||||
│ advance stage)
|
||||
│
|
||||
_finalize_job:
|
||||
exit 0 -> mark_job done
|
||||
exit !=0 & attempts<max -> requeue (queued)
|
||||
exit !=0 & attempts>=max -> failed + Telegram
|
||||
_finalize_job(result_ok):
|
||||
exit 0 & result_ok -> mark_job done
|
||||
else (exit!=0 ИЛИ пустой результат):
|
||||
attempts<max -> requeue (queued)
|
||||
attempts>=max -> failed + Telegram
|
||||
```
|
||||
|
||||
> ORCH-044 (P3): `result_ok` отражает валидность run-лога (непустой + есть result-JSON). `exit 0` с пустым/невалидным результатом идёт в ветку провала, НЕ в `done` (см. §4 «Валидация результата»).
|
||||
|
||||
### Таблица `jobs`
|
||||
|
||||
| Колонка | Назначение |
|
||||
|
||||
@@ -54,6 +54,9 @@
|
||||
| `ORCH_AGENT_EFFORT_DEFAULT` | режим работы `--effort` по умолчанию (ORCH-41): low\|medium\|high\|xhigh\|max; дефолт `high` |
|
||||
| `ORCH_AGENT_EFFORT_<AGENT>` | per-agent effort; дефолт: думающие → high, tester/deployer → medium |
|
||||
| `ORCH_AGENT_FALLBACK_MODEL` | опц. фолбэк-модель при overloaded (`--fallback-model`); пусто → без флага |
|
||||
| `ORCH_PREFLIGHT_CHECK_AUTH` | вкл/выкл token-free auth-проверку preflight (ORCH-044); дефолт `true`. Аварийный тумблер: `false` → preflight как до ORCH-044 (только `--version`) |
|
||||
| `ORCH_CLAUDE_CREDENTIALS_PATH` | явный путь к `.credentials.json` (ORCH-044); пусто → `<AGENT_HOME>/.claude/.credentials.json`, где `AGENT_HOME=/home/slin` — HOME, под которым launcher реально спавнит claude (не HOME процесса орка) |
|
||||
| `ORCH_AUTH_EXPIRY_SKEW_SECONDS` | запас на рассинхрон часов при сравнении `claudeAiOauth.expiresAt` (ORCH-044); дефолт `0` |
|
||||
| `DEPLOY_SSH_USER` / `_HOST` / `DEPLOY_HOOK_SCRIPT` | параметры деплой-хука |
|
||||
|
||||
**Секреты — только в `.env` / `.env.staging` на хосте, в гит НЕ коммитятся.** Канон — `.env.example`, `.env.staging.example`.
|
||||
@@ -81,6 +84,19 @@
|
||||
|
||||
> ⚠️ Бюджет (ORCH-38): `claude-opus-4-8` дефолт в коде; реальное переключение прод-env делается отдельно после согласования.
|
||||
|
||||
## Preflight auth-гейт (`src/preflight.py`, ORCH-044)
|
||||
`claude --version` отвечает успешно **даже когда claude разлогинен** (версия — локальная инфа), поэтому до ORCH-044 preflight был слеп к авторизации: разлогиненный инстанс клеймил job и тихо умирал с пустым логом, блокируя общую очередь всех проектов.
|
||||
|
||||
ORCH-044 добавляет **token-free** проверку (без сети, без prompt-ping — BR-1):
|
||||
1. **Проактивно (основной гейт):** после успешного `--version` читается `<AGENT_HOME>/.claude/.credentials.json` (путь — `ORCH_CLAUDE_CREDENTIALS_PATH` или дефолт от `AGENT_HOME=/home/slin`, **не** HOME процесса орка). Нет файла / битый JSON / нет `claudeAiOauth.accessToken` ⇒ `check()=(False, …)`. `claudeAiOauth.expiresAt` (epoch ms) `<= now + ORCH_AUTH_EXPIRY_SKEW_SECONDS` ⇒ протух ⇒ FAIL. Нет `expiresAt` ⇒ OK (не плодим ложные срабатывания). Результат кешируется тем же `ORCH_PREFLIGHT_CACHE_TTL`, что и `--version`.
|
||||
2. **Постфактум (защитная сетка):** если агент всё же стартовал при протухшей сессии, launcher детектит маркер (`not logged in` / `please run /login` / `unauthorized` / `401`) в run-логе и сбрасывает preflight-кеш, чтобы следующий тик переоценил auth. Auth-провал **не** считается transient и **не** крутит circuit breaker — гейт здесь preflight.
|
||||
|
||||
При `auth=fail` job **не клеймится** (`_drain_once` уже корректен при `ok=False`), reason виден в `/queue` (`preflight_reason`). Аварийный тумблер `ORCH_PREFLIGHT_CHECK_AUTH=false` возвращает version-only поведение.
|
||||
|
||||
> ⚠️ Риск ложноположительного auth-fail (R-1): неверный путь к credentials заблокирует клейм **всех** проектов (общая очередь). Митигация: единый источник `AGENT_HOME`, тумблер, обязательная проверка на staging (8501) перед прод-деплоем. ADR — `docs/work-items/ORCH-044/06-adr/ADR-001-preflight-auth-and-empty-result-failure.md`.
|
||||
|
||||
> ℹ️ `--effort` (P2) в ORCH-044 **не трогается** — вынесен в ORCH-50.
|
||||
|
||||
## ⚠️ Self-hosting — оркестратор дорабатывает САМ СЕБЯ
|
||||
|
||||
**Факт:** прод-инстанс `orchestrator` (8500) — ОДИН на ВСЕ прод-проекты (enduro-trails + orchestrator), с ОБЩЕЙ БД `./data/orchestrator.db` и общей очередью задач (ORCH-1).
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# Business Request: Telegram live-tracker: режим bump (карточка падает вниз при обновлении)
|
||||
|
||||
Work Item ID: ORCH-042
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
@@ -1,65 +0,0 @@
|
||||
# 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>
|
||||
@@ -1,118 +0,0 @@
|
||||
# 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>
|
||||
@@ -1,55 +0,0 @@
|
||||
# 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>
|
||||
@@ -1,160 +0,0 @@
|
||||
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>
|
||||
@@ -1,85 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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 нет.
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
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» выполнено.
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
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
|
||||
============================================================
|
||||
```
|
||||
7
docs/work-items/ORCH-044/00-business-request.md
Normal file
7
docs/work-items/ORCH-044/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: Надёжность запуска агента: preflight ловит auth+битый флаг, --effort фикс
|
||||
|
||||
Work Item ID: ORCH-044
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
90
docs/work-items/ORCH-044/01-brd.md
Normal file
90
docs/work-items/ORCH-044/01-brd.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 01 — Business Requirements Document (BRD)
|
||||
|
||||
**Work Item:** ORCH-044
|
||||
**Title:** Надёжность запуска агента: preflight ловит auth+битый флаг, --effort фикс
|
||||
**Приоритет:** Высокий (надёжность конвейера)
|
||||
**Автор запроса:** Слава, 05.06 («почему перед стартом аналитика не прошла проверка?»)
|
||||
|
||||
## 1. Контекст и инцидент (05.06)
|
||||
Задача **ORCH-17** застряла на стадии Analysis ~30 минут. Аналитик-агент стартовал и
|
||||
мгновенно «умирал»: run-лог — **пустой файл (0 байт)**, а job в очереди оставался в
|
||||
состоянии `running` (вечное зависание без сигнала).
|
||||
|
||||
Корневые причины (две, наложились):
|
||||
1. **`claude` Not logged in** после ребилда контейнера — токен/сессия не поднялись.
|
||||
2. **Флаг `--effort`** в связке с `--print --output-format json` (CLI 2.1.142) **гасил весь
|
||||
stdout** — claude завершался с пустым выводом.
|
||||
|
||||
**Главная системная проблема:** preflight-проверка пропустила обе битые задачи в работу —
|
||||
она слепа к авторизации и не ловит «битый флаг → пустой вывод».
|
||||
|
||||
## 2. Проблема (как есть)
|
||||
- **P1. Дыра в preflight (главное).** `src/preflight.py` сознательно проверяет только
|
||||
(a) `os.path.exists(CLAUDE_BIN)` и (b) `claude --version` (timeout 5s, без токенов).
|
||||
Но `--version` отвечает успешно **даже когда claude НЕ залогинен** (версия — локальная
|
||||
информация). Итог: `preflight=ok`, а реальный запуск падает `Not logged in`. Preflight
|
||||
слеп к авторизации и пропускает заведомо нерабочие задачи в очередь.
|
||||
- **P2. `--effort` ломает вывод.** Флаг `--effort <low..max>` совместно с
|
||||
`--print`/`--output-format json` в CLI 2.1.142 даёт **пустой stdout** — агент молча
|
||||
умирает. Сейчас effort **отключён в проде** хотфиксом (`.env`: `ORCH_AGENT_EFFORT_*=""`),
|
||||
но дефолты в `src/config.py` всё ещё `high`/`medium`, а документация (INFRA.md,
|
||||
internals.md, ORCH-41) описывает effort как рабочую фичу. Несоответствие кода/доков/прода.
|
||||
- **P3. Пустой лог ≠ провал.** Агент с пустым run-логом (0 байт) и `exit 0` трактуется как
|
||||
**успех** (`_finalize_job` → `done`, авто-advance стадии) либо вечно висит `running`.
|
||||
Ни watchdog, ни ретрай не срабатывают. Нет сигнала об инциденте.
|
||||
|
||||
## 3. Бизнес-последствия
|
||||
- Любой сбой авторизации или несовместимости флага → **тихое зависание** задачи без алерта.
|
||||
- Блокируется конвейер **всех** проектов (общий инстанс/очередь, self-hosting) — как было с
|
||||
ORCH-17 (30 мин простоя, ручное вмешательство).
|
||||
- Деградация доверия к автономности оркестратора: «проверка перед стартом» не работает.
|
||||
|
||||
## 4. Цель
|
||||
Сделать запуск агента **отказоустойчивым по входу и по выходу**:
|
||||
1. Preflight ловит отсутствие/протухание авторизации **дёшево и без траты токенов** до того,
|
||||
как job будет заклеймлен.
|
||||
2. Разобраться с `--effort` и привести код/доки/прод к одному непротиворечивому состоянию.
|
||||
3. Пустой/невалидный результат запуска трактуется как **провал** (job → `failed`), чтобы
|
||||
сработали watchdog/ретрай и алерт, а не вечное зависание.
|
||||
|
||||
## 5. Заинтересованные стороны
|
||||
- **Owner/Слава** — инициатор, требует «проверки перед стартом».
|
||||
- **Все проекты на инстансе** (enduro-trails и self-hosting ORCH) — страдают от простоя.
|
||||
- **Агенты конвейера** — analyst/architect/... — все запускаются через единый launcher.
|
||||
|
||||
## 6. Объём (Scope)
|
||||
**В объёме:**
|
||||
- Дешёвая token-free проверка авторизации в preflight.
|
||||
- Расследование и решение по `--effort` (вернуть корректно ИЛИ задокументировать как
|
||||
unsupported и убрать из кода/дефолтов/доков).
|
||||
- Детекция «пустой лог / нет валидного result-JSON» как провала job с корректным
|
||||
переводом в `failed` и срабатыванием ретрая/алерта.
|
||||
- Обновление документации (INFRA.md / internals.md / CHANGELOG) в том же PR.
|
||||
|
||||
**Вне объёма:**
|
||||
- Prompt-ping (ping→pong) — **запрещено** (жжёт rate limit). Только локальные/дешёвые проверки.
|
||||
- Реформа circuit breaker / backoff-логики (используем существующие механизмы).
|
||||
- Изменение схемы стадий/конвейера.
|
||||
- Автоматический re-login claude (восстановление авторизации) — отдельная задача.
|
||||
|
||||
## 7. Бизнес-правила
|
||||
- BR-1: Preflight **не тратит токены** и не делает сетевых вызовов к API модели.
|
||||
- BR-2: Протухшая/нечитаемая авторизация → `preflight=fail` → job **не клеймится** (остаётся
|
||||
`queued`), пишется warning, при необходимости — алерт/брейкер.
|
||||
- BR-3: Пустой run-лог ИЛИ отсутствие валидного result-JSON при `exit 0` → job `failed`
|
||||
(никогда не `done` и не вечный `running`).
|
||||
- BR-4: Никаких `--no-verify`/обхода хуков без явного одобрения Owner.
|
||||
- BR-5: Код, дефолты `config.py`, прод `.env` и документация по `--effort` должны быть
|
||||
взаимно непротиворечивы после задачи.
|
||||
|
||||
## 8. Критерии успеха (бизнес-уровень)
|
||||
- Симуляция «не залогинен» → preflight ловит до клейма, job не стартует впустую.
|
||||
- Симуляция «пустой лог + exit 0» → job становится `failed`, срабатывает ретрай/алерт.
|
||||
- Состояние `--effort` однозначно: либо работает с json-форматом, либо удалён из активного
|
||||
пути и доков (без «мёртвого» флага в дефолтах).
|
||||
- Инцидент класса ORCH-17 больше не приводит к тихому 30-минутному зависанию.
|
||||
|
||||
## 9. Связанные материалы
|
||||
- `src/preflight.py`, `src/queue_worker.py`, `src/agents/launcher.py`, `src/config.py`
|
||||
- `docs/history/LESSONS_ORCH-017.md`, `docs/history/LESSONS_2026-06-05.md`
|
||||
- ORCH-41 (effort/model resolver), ORCH-1 (очередь/resilience), ORCH-7 (watchdog)
|
||||
143
docs/work-items/ORCH-044/02-trz.md
Normal file
143
docs/work-items/ORCH-044/02-trz.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# 02 — Техническое задание (ТЗ)
|
||||
|
||||
**Work Item:** ORCH-044
|
||||
**Основано на:** 01-brd.md
|
||||
|
||||
> Примечание: ТЗ фиксирует **что** должно измениться и **наблюдаемое поведение**.
|
||||
> Выбор конкретной реализации (например, формат проверки `.credentials.json` vs парсинг
|
||||
> маркера в логе) — за архитектором (стадия architecture, ADR). Где описаны варианты —
|
||||
> это границы допустимого решения, а не предписание.
|
||||
|
||||
> ## ⛔ КОРРЕКЦИЯ SCOPE ВЛАДЕЛЬЦЕМ (Слава, 06.06) — ЧИТАТЬ ПЕРВЫМ
|
||||
>
|
||||
> **P2 (`--effort`) ПОЛНОСТЬЮ ИСКЛЮЧЁН из этой задачи.** Решение владельца:
|
||||
> - effort **НУЖЕН и работает** — его **НЕЛЬЗЯ** убирать как unsupported. **Вариант B запрещён.**
|
||||
> - В ORCH-044 **НЕ трогать** `--effort`: ни `_spawn` effort_flag, ни `resolve_agent_effort`, ни дефолты `agent_effort_*` в `config.py`, ни ORCH-41 effort-доки.
|
||||
> - Текущий прод-хотфикс `ORCH_AGENT_EFFORT_*=""` в `.env` **оставить как есть** — не снимать, не менять.
|
||||
> - Полноценный возврат effort (расследование флагов + json) вынесен в **ОТДЕЛЬНУЮ задачу ORCH-50** («Эффорт агентов: заставить --effort работать с --print/json»). Туда же — любое расследование причины пустого stdout.
|
||||
>
|
||||
> **Архитектор/дев игнорируют все TR-2.x и AC-7/AC-8/AC-9, относящиеся к effort.** Реализуем ТОЛЬКО:
|
||||
> - **P1** — preflight ловит auth (ОБА подхода: проактивно cred-файл `expiresAt` + постфактум маркер `Not logged in`);
|
||||
> - **P3** — пустой лог / нет result-JSON ⇒ job `failed` (не `done`, не вечный `running`).
|
||||
>
|
||||
> Заголовок задачи содержит «--effort фикс» по историческим причинам — это НЕ часть scope. Effort = ORCH-50.
|
||||
|
||||
## 1. Задействованные модули `src/`
|
||||
| Модуль | Текущее место | Изменение |
|
||||
|--------|---------------|-----------|
|
||||
| `src/preflight.py` | `_run_version`, `_compute`, `check` | Добавить дешёвую token-free проверку авторизации (P1) |
|
||||
| `src/config.py` | блок ORCH-41 effort (стр. 98–108), новый блок настроек preflight-auth | Настройки auth-проверки; решение по effort-дефолтам (P2) |
|
||||
| `src/agents/launcher.py` | `_spawn` (effort_flag, стр. 290–292, 303–311), `_monitor_agent` (стр. 460–615), `_finalize_job` (стр. 630–667) | Решение по `--effort` (P2); детекция пустого лога / отсутствия result-JSON (P3) |
|
||||
| `src/queue_worker.py` | `_drain_once` claim-gating (стр. 158–165) | Учесть новый auth-fail preflight в гейтинге клейма (P1) — при необходимости |
|
||||
| `src/db.py` | `mark_job` | Использование существующего перевода job → `failed` (P3); новых колонок не требуется |
|
||||
|
||||
Новых файлов модулей не предполагается обязательно; допускается выделение хелпера
|
||||
(например, `_check_auth()` в `preflight.py`) — на усмотрение архитектора.
|
||||
|
||||
## 2. Требования по проблемам
|
||||
|
||||
### P1 — Preflight ловит авторизацию (token-free)
|
||||
- **TR-1.1.** Preflight ДОЛЖЕН, помимо `os.path.exists(bin)` и `claude --version`, выполнять
|
||||
**дешёвую проверку авторизации без обращения к API модели и без prompt-ping**.
|
||||
- **TR-1.2.** Допустимые подходы (выбор — за архитектором, ADR):
|
||||
- (a) Проверка существования и читаемости файла учётных данных
|
||||
`~/.claude/.credentials.json` (HOME агента — `/home/slin`, см. launcher env, стр. 326)
|
||||
и валидности OAuth-токена по дате истечения внутри
|
||||
(`claudeAiOauth.expiresAt`, epoch ms) — `expiresAt <= now` ⇒ протух ⇒ fail;
|
||||
- (b) Парсинг реального run-вывода на маркер `Not logged in` (и подобные) с переводом
|
||||
job в провал и размыканием/учётом circuit breaker.
|
||||
- Подход (a) предпочтителен как **проактивный** (ловит ДО клейма); (b) — как защитная
|
||||
сетка постфактум. Допускается комбинация.
|
||||
- **TR-1.3.** Путь к файлу учётных данных ДОЛЖЕН резолвиться согласованно с тем HOME,
|
||||
под которым launcher реально спавнит claude (`/home/slin`), а не из окружения процесса
|
||||
оркестратора (аналогично тому, как `_claude_bin()` следует за реально исполняемым путём).
|
||||
- **TR-1.4.** Результат auth-проверки кешируется тем же механизмом, что и version-check
|
||||
(`preflight_cache_ttl`), чтобы не читать файл на каждый тик воркера.
|
||||
- **TR-1.5.** При `auth=fail`: `check()` возвращает `(False, reason)` с **информативным
|
||||
reason** (например, `claude not logged in: credentials missing` / `OAuth token expired at
|
||||
<iso>`). Job НЕ клеймится (поведение `_drain_once` уже корректно при `ok=False`).
|
||||
- **TR-1.6.** Граница ответственности: preflight остаётся **локальным** (BR-1). Сетевая
|
||||
валидация токена у провайдера — вне объёма.
|
||||
- **TR-1.7.** Поведение при «всё хорошо» не меняется: залогинен + валидный токен ⇒ `ok=True`.
|
||||
|
||||
### P2 — Решение по `--effort`
|
||||
- **TR-2.1.** Провести расследование (стадия architecture/development): причина пустого
|
||||
stdout при `--effort` + `--print --output-format json` в CLI 2.1.142 — несовместимость
|
||||
с json-форматом, иной синтаксис флага, или баг CLI. Зафиксировать вывод в ADR/`10-tech-risks.md`.
|
||||
- **TR-2.2.** По итогам выбрать **ровно один** исход и привести к нему код+доки+дефолты:
|
||||
- **Вариант A (вернуть effort):** найден корректный способ (например, иной синтаксис или
|
||||
несовместимость только с конкретным output-format) — `--effort` снова формируется в
|
||||
`_spawn` корректно; прод-хотфикс `ORCH_AGENT_EFFORT_*=""` снимается; добавить
|
||||
регресс-тест, что вывод не пустой.
|
||||
- **Вариант B (unsupported):** effort несовместим — **убрать `--effort` из активного пути
|
||||
запуска** (`_spawn` не формирует `effort_flag`), убрать/нейтрализовать дефолты effort в
|
||||
`config.py`, обновить ORCH-41-доки (INFRA.md, internals.md) пометив фичу как unsupported
|
||||
на данной версии CLI. `resolve_agent_effort` либо удаляется, либо документированно
|
||||
оставляется заглушкой (решение — ADR).
|
||||
- **TR-2.3.** Независимо от A/B: **не должно остаться «мёртвого» флага**, который тихо гасит
|
||||
вывод. После задачи запуск с дефолтной конфигурацией прода ДОЛЖЕН давать непустой
|
||||
result-JSON.
|
||||
- **TR-2.4.** Изменение дефолтов/удаление флага не должно ломать `resolve_agent_model`
|
||||
(модель — независимая фича ORCH-41) и существующие тесты `test_resolve_agent_effort.py`
|
||||
(их допустимо обновить под новый контракт).
|
||||
|
||||
### P3 — Пустой лог / нет result-JSON ⇒ провал
|
||||
- **TR-3.1.** В `_monitor_agent`/`_finalize_job`: при `exit_code == 0` ДОЛЖНА выполняться
|
||||
**проверка валидности результата** перед тем как считать job успешным:
|
||||
- run-лог **непустой** (размер > 0 и/или содержит непустой текст), И
|
||||
- из него извлекается **валидный result-JSON** (тот же контракт, что использует
|
||||
`usage._extract_last_json_object` / `parse_usage_from_log`).
|
||||
- **TR-3.2.** Если результат невалиден (пустой лог ИЛИ нет валидного JSON) при `exit_code==0`,
|
||||
job ДОЛЖЕН трактоваться как **провал**:
|
||||
- НЕ переводиться в `done`;
|
||||
- попасть в путь ретрая/провала (`attempts < max_attempts` ⇒ requeue, иначе `failed`),
|
||||
аналогично permanent-ветке `_finalize_permanent`, с информативным `error`
|
||||
(например, `empty run log / no result JSON (run_id=...)`);
|
||||
- сгенерировать алерт (Telegram), как прочие провалы;
|
||||
- НЕ выполнять авто-advance стадии (`_try_advance_stage`) и НЕ постить «успешный»
|
||||
status-коммент.
|
||||
- **TR-3.3.** Классификация такого провала: по умолчанию — **permanent** (это не 429/overload).
|
||||
Если в логе присутствует transient-маркер (через `error_classifier`) — допускается
|
||||
transient-путь. Auth-провал (`Not logged in`) — на усмотрение архитектора: может
|
||||
маршрутизироваться как сигнал брейкеру (P1/TR-1.2b).
|
||||
- **TR-3.4.** Никогда не оставлять job в `running` навечно из-за пустого результата: либо
|
||||
`done` (валидно), либо `failed`/`queued`(retry). (Watchdog ORCH-7 продолжает закрывать
|
||||
случай таймаута; здесь закрывается случай «быстрая смерть с exit 0».)
|
||||
- **TR-3.5.** Защитность: вся проверка обёрнута так, что её собственная ошибка не роняет
|
||||
монитор (как и прочий код `_monitor_agent`); при сомнении — fail-safe в сторону провала job.
|
||||
|
||||
## 3. Изменения API
|
||||
Нет новых/изменённых HTTP-endpoint'ов. Допускается обогащение поля `preflight_reason` в
|
||||
`/queue` (через существующий `worker.status()` / `QueueWorker.last_preflight_reason`) более
|
||||
информативным auth-сообщением — без изменения схемы ответа.
|
||||
|
||||
## 4. Изменения схемы БД
|
||||
Нет. Используются существующие колонки `jobs` (`status`, `error`, `attempts`,
|
||||
`max_attempts`, `transient_attempts`) и `agent_runs`. Новых таблиц/колонок не требуется.
|
||||
|
||||
## 5. Требования к новым QG checks
|
||||
Новых Quality Gate проверок не требуется — изменения в слое запуска/preflight, не в гейтах
|
||||
стадий. Реестр `QG_CHECKS` не меняется.
|
||||
|
||||
## 6. Конфигурация (env / config.py)
|
||||
- Возможные новые настройки preflight-auth (имена — на усмотрение архитектора), например:
|
||||
- `ORCH_PREFLIGHT_CHECK_AUTH` (bool, default true) — включение auth-проверки;
|
||||
- путь к credentials, если не выводится из HOME автоматически.
|
||||
- Решение по effort-дефолтам (`agent_effort_*`) согласно TR-2.2 (нейтрализовать при варианте B).
|
||||
- Все новые настройки документируются в `config.py` docstring и в INFRA.md (env-карта).
|
||||
|
||||
## 7. Артефакты pipeline (обязательны к созданию/обновлению)
|
||||
- `06-adr/ADR-NNN-*.md` — решение по подходу preflight-auth (a/b/комбо) и по effort (A/B).
|
||||
- `10-tech-risks.md` — риск ложноположительной auth-проверки, риск регрессии effort, риск
|
||||
fail-safe-провала на легитимных пустых выводах.
|
||||
- `12-review.md`, `13-test-report.md` — по стадиям.
|
||||
- Обновить `docs/operations/INFRA.md` и `docs/architecture/internals.md` (effort-секции),
|
||||
`CHANGELOG.md`. Документация = golden source (правило агентов №2).
|
||||
|
||||
## 8. Ограничения и запреты
|
||||
- ❌ Prompt-ping в preflight (жжёт rate limit) — запрещено (BR-1, комментарий в preflight.py).
|
||||
- ❌ Сетевые вызовы к API модели в preflight.
|
||||
- ❌ Оставлять job в `running` без таймаута при пустом результате.
|
||||
- ❌ `--no-verify`/обход хуков без одобрения Owner.
|
||||
- ⚠️ Self-hosting: не ронять прод-контейнер `orchestrator`; проверка изменений — через
|
||||
staging (8501) перед прод-деплоем (см. CLAUDE.md, INFRA.md).
|
||||
122
docs/work-items/ORCH-044/03-acceptance-criteria.md
Normal file
122
docs/work-items/ORCH-044/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# 03 — Критерии приёмки (Acceptance Criteria)
|
||||
|
||||
**Work Item:** ORCH-044
|
||||
Каждый критерий — однозначное PASS/FAIL. Привязка к TR из `02-trz.md`.
|
||||
|
||||
## P1 — Preflight ловит авторизацию
|
||||
|
||||
### AC-1 — Не залогинен ⇒ preflight FAIL (TR-1.1, TR-1.2, TR-1.5)
|
||||
- **Дано:** бинарь claude существует, `claude --version` отвечает успешно, НО учётные
|
||||
данные отсутствуют/нечитаемы (нет `.credentials.json`).
|
||||
- **Когда:** вызывается `preflight.check(force=True)`.
|
||||
- **Тогда:** возвращается `(False, reason)`, где `reason` упоминает авторизацию
|
||||
(например, «not logged in» / «credentials»).
|
||||
- **FAIL если:** возвращается `(True, ...)` (как сейчас — слепота к auth).
|
||||
|
||||
### AC-2 — Протухший OAuth-токен ⇒ preflight FAIL (TR-1.2a)
|
||||
- **Дано:** `.credentials.json` существует и читаем, но `claudeAiOauth.expiresAt` в прошлом.
|
||||
- **Когда:** `preflight.check(force=True)`.
|
||||
- **Тогда:** `(False, reason)` с указанием на истечение токена.
|
||||
- *(N/A, если архитектор выбрал чистый вариант (b) без чтения файла — тогда покрывается AC-9.)*
|
||||
|
||||
### AC-3 — Валидный логин ⇒ preflight OK без регрессии (TR-1.7)
|
||||
- **Дано:** bin есть, `--version` ок, `.credentials.json` читаем, `expiresAt` в будущем.
|
||||
- **Когда:** `preflight.check(force=True)`.
|
||||
- **Тогда:** `(True, ...)`.
|
||||
- **FAIL если:** залогиненный валидный кейс даёт FAIL (ложное срабатывание).
|
||||
|
||||
### AC-4 — Auth-fail блокирует клейм job (TR-1.5, BR-2)
|
||||
- **Дано:** preflight возвращает `(False, ...)` из-за auth; в очереди есть `queued` job.
|
||||
- **Когда:** `QueueWorker._drain_once()` выполняет тик.
|
||||
- **Тогда:** job **не клеймится** (остаётся `queued`), в `worker.last_preflight_ok=False`,
|
||||
пишется лог-warning; claude не спавнится.
|
||||
- **FAIL если:** job переходит в `running` / спавнится агент.
|
||||
|
||||
### AC-5 — Token-free и локально (BR-1, TR-1.6)
|
||||
- **Дано:** auth-проверка.
|
||||
- **Тогда:** она НЕ делает prompt-ping и НЕ обращается к API модели (никаких httpx/сетевых
|
||||
вызовов к провайдеру в пути проверки; проверяется по коду/моку — сетевой вызов не
|
||||
происходит).
|
||||
- **FAIL если:** проверка отправляет запрос к модели/жжёт токены.
|
||||
|
||||
### AC-6 — Кеширование auth-проверки (TR-1.4)
|
||||
- **Дано:** `preflight_cache_ttl` > 0, первый `check()` выполнен.
|
||||
- **Когда:** повторные `check()` в пределах TTL.
|
||||
- **Тогда:** дорогая часть (чтение файла/процесс) не повторяется чаще TTL (как у version-check).
|
||||
- **FAIL если:** файл/процесс дёргается на каждый тик внутри TTL.
|
||||
|
||||
## P2 — Решение по `--effort`
|
||||
|
||||
> ⛔ **ИСКЛЮЧЕНО ВЛАДЕЛЬЦЕМ (06.06):** AC-7, AC-8, AC-9 НЕ применяются в ORCH-044. effort не трогаем, вынесен в ORCH-50. См. коррекцию scope в 02-trz.md.
|
||||
|
||||
|
||||
### AC-7 — Расследование задокументировано (TR-2.1)
|
||||
- **Тогда:** в ADR (`06-adr/`) и/или `10-tech-risks.md` зафиксирована причина пустого stdout
|
||||
при `--effort` + `--print --output-format json` (несовместимость/синтаксис/баг CLI).
|
||||
- **FAIL если:** изменения внесены без объяснения первопричины.
|
||||
|
||||
### AC-8 — Однозначный исход A или B, без «мёртвого» флага (TR-2.2, TR-2.3)
|
||||
- **Тогда:** реализован ровно один из вариантов:
|
||||
- **A:** `--effort` формируется и запуск с ним даёт **непустой** result-JSON; прод-хотфикс
|
||||
`ORCH_AGENT_EFFORT_*=""` более не требуется; есть регресс-тест на непустой вывод; ИЛИ
|
||||
- **B:** `--effort` **не формируется** в активном пути `_spawn`; дефолты `agent_effort_*`
|
||||
нейтрализованы; ORCH-41-доки помечают effort как unsupported на текущем CLI.
|
||||
- **FAIL если:** в коде остаётся путь, где дефолтная конфигурация добавляет `--effort` и
|
||||
гасит вывод; ИЛИ код/доки/дефолты противоречат друг другу.
|
||||
|
||||
### AC-9 — Дефолтный запуск даёт непустой результат (TR-2.3, перекликается с P3)
|
||||
- **Дано:** конфигурация по умолчанию после задачи (без ручного хотфикса в `.env`).
|
||||
- **Когда:** агент запускается стандартным путём `_spawn`.
|
||||
- **Тогда:** результат запуска — непустой run-лог с валидным result-JSON (проверяемо
|
||||
модульно через построение cmd и/или интеграционно на моке claude).
|
||||
- **FAIL если:** дефолтный путь воспроизводит пустой stdout инцидента.
|
||||
|
||||
## P3 — Пустой лог / нет result-JSON ⇒ провал
|
||||
|
||||
### AC-10 — Пустой лог + exit 0 ⇒ job НЕ done (TR-3.1, TR-3.2)
|
||||
- **Дано:** агент завершился `exit_code=0`, но run-лог пустой (0 байт).
|
||||
- **Когда:** отрабатывает `_monitor_agent`/`_finalize_job`.
|
||||
- **Тогда:** job НЕ переходит в `done`; переходит в `failed` (или `queued` при наличии
|
||||
retry-бюджета) с информативным `error`; шлётся алерт.
|
||||
- **FAIL если:** job становится `done`, либо остаётся `running` навсегда.
|
||||
|
||||
### AC-11 — Нет валидного result-JSON + exit 0 ⇒ job НЕ done (TR-3.1, TR-3.2)
|
||||
- **Дано:** run-лог непустой, но не содержит валидного result-JSON (мусор/обрезок).
|
||||
- **Когда:** финализация job.
|
||||
- **Тогда:** job трактуется как провал (как AC-10).
|
||||
- **FAIL если:** job становится `done`.
|
||||
|
||||
### AC-12 — Нет авто-advance и нет «успешного» коммента при провале результата (TR-3.2)
|
||||
- **Дано:** кейс AC-10/AC-11.
|
||||
- **Тогда:** `_try_advance_stage` НЕ вызывается (стадия не двигается), «успешный»
|
||||
status-коммент агента НЕ постится.
|
||||
- **FAIL если:** стадия продвинулась/запостился успех при пустом результате.
|
||||
|
||||
### AC-13 — Валидный результат не регрессирует (TR-3.1)
|
||||
- **Дано:** `exit_code=0` и непустой run-лог с валидным result-JSON.
|
||||
- **Когда:** финализация job.
|
||||
- **Тогда:** job → `done`, авто-advance и usage-коммент работают как раньше.
|
||||
- **FAIL если:** легитимный успешный запуск теперь ошибочно помечается провалом.
|
||||
|
||||
### AC-14 — Никогда не вечный `running` (TR-3.4, BR-3)
|
||||
- **Тогда:** для любого завершившегося процесса (любой exit_code, включая 0 с пустым логом)
|
||||
job завершается в терминальном/ретраябельном состоянии (`done`/`failed`/`queued`), не
|
||||
остаётся `running`.
|
||||
- **FAIL если:** существует путь, оставляющий job `running` после выхода процесса.
|
||||
|
||||
## Сквозные
|
||||
|
||||
### AC-15 — Документация обновлена в том же PR (правило агентов №2, №6)
|
||||
- **Тогда:** обновлены `docs/operations/INFRA.md` (env-карта preflight-auth и/или effort),
|
||||
`docs/architecture/internals.md` (effort-секция), `CHANGELOG.md`; заведён ADR.
|
||||
- **FAIL если:** функционал изменён, доки/CHANGELOG/ADR не обновлены (reviewer → REQUEST_CHANGES).
|
||||
|
||||
### AC-16 — Тесты зелёные (test-plan)
|
||||
- **Тогда:** все тесты из `04-test-plan.yaml` проходят; `pytest tests/ -q` зелёный.
|
||||
- **FAIL если:** хотя бы один тест плана FAIL или существующие тесты сломаны без обоснованного
|
||||
обновления контракта.
|
||||
|
||||
### AC-17 — Self-hosting безопасность (CLAUDE.md)
|
||||
- **Тогда:** изменения не требуют рестарта/падения прод-контейнера `orchestrator` в рамках
|
||||
задачи; проверка прошла через staging (8501).
|
||||
- **FAIL если:** задача ломает/рестартует прод-инстанс, останавливая конвейер других проектов.
|
||||
145
docs/work-items/ORCH-044/04-test-plan.yaml
Normal file
145
docs/work-items/ORCH-044/04-test-plan.yaml
Normal file
@@ -0,0 +1,145 @@
|
||||
work_item: ORCH-044
|
||||
title: "Надёжность запуска агента: preflight auth + --effort фикс + пустой лог = провал"
|
||||
notes: >
|
||||
Реальный claude/Popen НЕ спавнится: subprocess и launcher мокаются (паттерн
|
||||
tests/test_resilience.py). БД — свежий per-test sqlite (fixture fresh_db).
|
||||
Файлы учётных данных создаются во временном каталоге (tmp_path) и путь
|
||||
мокается. Сетевые вызовы запрещены — проверяются моками/отсутствием httpx.
|
||||
|
||||
tests:
|
||||
# ---------------- P1: preflight ловит авторизацию ----------------
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "Нет .credentials.json при рабочем --version -> preflight.check() = (False, reason про auth)"
|
||||
module: tests/test_preflight_auth.py
|
||||
covers: [AC-1, TR-1.1, TR-1.2]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "Протухший OAuth (claudeAiOauth.expiresAt в прошлом) -> preflight FAIL про истечение токена"
|
||||
module: tests/test_preflight_auth.py
|
||||
covers: [AC-2, TR-1.2a]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "Валидный логин (credentials читаемы, expiresAt в будущем) -> preflight OK, без регрессии"
|
||||
module: tests/test_preflight_auth.py
|
||||
covers: [AC-3, TR-1.7]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "Нечитаемый/битый .credentials.json (невалидный JSON) -> preflight FAIL, не падает исключением"
|
||||
module: tests/test_preflight_auth.py
|
||||
covers: [AC-1, TR-1.2a, TR-3.5]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "Auth-проверка token-free: при check() не происходит сетевого вызова к API модели (мок httpx/urlopen не вызван)"
|
||||
module: tests/test_preflight_auth.py
|
||||
covers: [AC-5, BR-1, TR-1.6]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "Auth-результат кешируется: повторные check() в пределах preflight_cache_ttl не перечитывают credentials"
|
||||
module: tests/test_preflight_auth.py
|
||||
covers: [AC-6, TR-1.4]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "Путь к credentials резолвится от HOME агента (/home/slin), а не от окружения процесса оркестратора"
|
||||
module: tests/test_preflight_auth.py
|
||||
covers: [TR-1.3]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: integration
|
||||
description: "QueueWorker._drain_once при preflight auth-fail не клеймит job: job остаётся queued, claude не спавнится, last_preflight_ok=False"
|
||||
module: tests/test_preflight_auth.py
|
||||
covers: [AC-4, BR-2, TR-1.5]
|
||||
expected: PASS
|
||||
|
||||
# ---------------- P2: решение по --effort ----------------
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "Вариант B: при дефолтной конфигурации построенная cmd в _spawn НЕ содержит '--effort' (флаг не гасит вывод). При варианте A — тест адаптируется на корректное формирование effort"
|
||||
module: tests/test_effort_flag.py
|
||||
covers: [AC-8, TR-2.2, TR-2.3]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "resolve_agent_effort согласован с принятым решением (B: нейтрализован/пусто по дефолту; A: валидное значение). Существующий test_resolve_agent_effort обновлён под новый контракт и зелёный"
|
||||
module: tests/test_resolve_agent_effort.py
|
||||
covers: [AC-8, TR-2.4]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-11
|
||||
type: integration
|
||||
description: "Дефолтный путь запуска (мок claude, отдающий валидный result-JSON) даёт непустой лог с валидным JSON — воспроизведение инцидента (пустой stdout) не происходит"
|
||||
module: tests/test_effort_flag.py
|
||||
covers: [AC-9, TR-2.3]
|
||||
expected: PASS
|
||||
|
||||
# ---------------- P3: пустой лог / нет result-JSON = провал ----------------
|
||||
- id: TC-12
|
||||
type: integration
|
||||
description: "exit_code=0 + пустой run-лог (0 байт) -> job НЕ done; помечается failed (или queued при retry-бюджете) с информативным error; алерт вызван"
|
||||
module: tests/test_empty_log_failure.py
|
||||
covers: [AC-10, TR-3.1, TR-3.2]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-13
|
||||
type: integration
|
||||
description: "exit_code=0 + лог без валидного result-JSON (мусор) -> job трактуется как провал, не done"
|
||||
module: tests/test_empty_log_failure.py
|
||||
covers: [AC-11, TR-3.1]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-14
|
||||
type: integration
|
||||
description: "При провале по пустому результату _try_advance_stage НЕ вызывается и успешный usage-коммент НЕ постится"
|
||||
module: tests/test_empty_log_failure.py
|
||||
covers: [AC-12, TR-3.2]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-15
|
||||
type: integration
|
||||
description: "exit_code=0 + непустой лог с валидным result-JSON -> job done, авто-advance и usage-коммент работают (нет регрессии)"
|
||||
module: tests/test_empty_log_failure.py
|
||||
covers: [AC-13, TR-3.1]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-16
|
||||
type: integration
|
||||
description: "Любой выход процесса не оставляет job в 'running': пустой лог+exit0 завершается терминально (done/failed/queued)"
|
||||
module: tests/test_empty_log_failure.py
|
||||
covers: [AC-14, BR-3, TR-3.4]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-17
|
||||
type: unit
|
||||
description: "Классификация пустого результата по умолчанию permanent; transient-маркер в логе уводит в transient-путь (error_classifier)"
|
||||
module: tests/test_empty_log_failure.py
|
||||
covers: [TR-3.3]
|
||||
expected: PASS
|
||||
|
||||
# ---------------- Регрессия / сквозное ----------------
|
||||
- id: TC-18
|
||||
type: unit
|
||||
description: "Регресс: существующие preflight-кейсы (bin missing, --version ok) из test_resilience.py остаются зелёными после добавления auth-слоя"
|
||||
module: tests/test_resilience.py
|
||||
covers: [AC-3, TR-1.7]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-19
|
||||
type: integration
|
||||
description: "Полный прогон 'pytest tests/ -q' зелёный — ни один существующий тест не сломан без обоснованного обновления контракта"
|
||||
module: tests/
|
||||
covers: [AC-16]
|
||||
expected: PASS
|
||||
@@ -0,0 +1,168 @@
|
||||
# ADR-001: Token-free auth-preflight + «пустой результат = провал» в запуске агента
|
||||
|
||||
**Work Item:** ORCH-044
|
||||
**Статус:** Accepted
|
||||
**Дата:** 2026-06-06
|
||||
**Автор:** Architect
|
||||
|
||||
> ⛔ **Scope (коррекция владельца, 06.06):** `--effort` (P2) **исключён** из ORCH-044 и
|
||||
> вынесен в **ORCH-50**. Этот ADR покрывает только **P1** (preflight ловит авторизацию)
|
||||
> и **P3** (пустой лог / нет result-JSON ⇒ job `failed`). Любые решения по effort,
|
||||
> дефолтам `agent_effort_*` и ORCH-41 effort-докам — **вне этого ADR**.
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Инцидент 05.06 (ORCH-17): аналитик-агент стартовал и мгновенно «умирал» — run-лог пустой
|
||||
(0 байт), job в очереди завис в `running`. Две наложившиеся причины: (1) `claude Not logged
|
||||
in` после ребилда контейнера; (2) `--effort` гасил stdout. **Системная проблема:**
|
||||
preflight пропустил заведомо нерабочую задачу в работу, а пустой результат был неотличим
|
||||
от успеха. Поскольку инстанс общий для всех проектов (self-hosting, общая очередь/БД),
|
||||
тихое зависание блокирует конвейер **всех** проектов.
|
||||
|
||||
Текущее состояние слоя запуска:
|
||||
- `src/preflight.py` проверяет только `os.path.exists(bin)` и `claude --version`. `--version`
|
||||
отвечает успешно **даже когда claude не залогинен** (версия — локальная информация) ⇒
|
||||
preflight слеп к авторизации.
|
||||
- `src/agents/launcher.py::_monitor_agent` трактует `exit_code == 0` как успех **независимо
|
||||
от формы stdout** (комментарий в `_spawn`, стр. 302) ⇒ пустой лог + exit 0 → `done` +
|
||||
авто-advance стадии.
|
||||
|
||||
Ограничения (BR-1): preflight обязан быть **локальным и token-free** — никакого prompt-ping
|
||||
и сетевых вызовов к API модели.
|
||||
|
||||
## Решение
|
||||
|
||||
### P1 — Preflight ловит авторизацию (комбинация проактивной и постфактум-проверок)
|
||||
|
||||
Реализуем **оба** подхода из TR-1.2 (a + b), проактивный — основной гейт, постфактум —
|
||||
защитная сетка.
|
||||
|
||||
**(a) Проактивно — чтение файла учётных данных (основной гейт).**
|
||||
`preflight._compute()` после успешного `--version` выполняет `_check_auth()`:
|
||||
1. Резолвит путь к credentials **согласованно с HOME, под которым launcher реально спавнит
|
||||
claude** (`/home/slin`), а НЕ из окружения процесса оркестратора. Реализуется зеркально
|
||||
`_claude_bin()`: новый `_agent_home()` читает `AgentLauncher.AGENT_HOME` (новая константа,
|
||||
значение `/home/slin`), путь = `settings.claude_credentials_path` если задан, иначе
|
||||
`<AGENT_HOME>/.claude/.credentials.json`.
|
||||
2. Файла нет / нечитаем / невалидный JSON ⇒ `(False, "claude not logged in: credentials …")`.
|
||||
3. Нет блока `claudeAiOauth` / accessToken ⇒ `(False, "not logged in: no oauth token")`.
|
||||
4. `claudeAiOauth.expiresAt` (epoch **ms**) `<= now_ms (+ skew)` ⇒
|
||||
`(False, "OAuth token expired at <iso>")`.
|
||||
5. accessToken есть, но `expiresAt` отсутствует/не число ⇒ **OK** (нельзя доказать истечение;
|
||||
не плодим ложные срабатывания — см. Риски).
|
||||
6. Иначе ⇒ `(True, "auth ok")`.
|
||||
|
||||
`_check_auth()` **никогда не бросает**: любое исключение → `(False, "auth check error: …")`
|
||||
(fail-safe в сторону «не клеймить», BR-2 / TR-3.5).
|
||||
|
||||
Кеширование (TR-1.4 / AC-6): чтение файла встроено в `_compute()`, который уже кешируется
|
||||
`check()` на `preflight_cache_ttl`. **Отдельный кеш не вводится** — auth-чтение происходит
|
||||
только на cache-miss, как и `--version`.
|
||||
|
||||
Гейтинг клейма (TR-1.5 / AC-4 / BR-2): **изменений в `queue_worker._drain_once` не требуется**
|
||||
— он уже не клеймит job при `ok=False`. Информативный auth-reason автоматически попадает в
|
||||
`worker.last_preflight_reason` и `/queue` (без изменения схемы ответа).
|
||||
|
||||
**(b) Постфактум — маркер `Not logged in` в run-логе (защитная сетка).**
|
||||
Если агент всё-таки стартовал при протухшей сессии (гонка: токен истёк между preflight и
|
||||
спавном), `launcher` при финализации детектит auth-маркер в логе
|
||||
(`preflight.is_auth_failure_text(text)`: «not logged in», «please run /login»,
|
||||
«unauthorized», «401») и:
|
||||
- включает маркер в `error` job;
|
||||
- вызывает `preflight.reset_cache()`, чтобы **следующий тик воркера переоценил auth
|
||||
проактивно** (быстрый подхват re-login ИЛИ дальнейшее гейтирование, если всё ещё битый).
|
||||
|
||||
Auth-провал **не** маршрутизируется как transient (это не 429) и **не** крутит брейкер —
|
||||
правильный механизм гейтирования здесь preflight, а не circuit breaker.
|
||||
|
||||
### P3 — Пустой лог / нет result-JSON ⇒ провал job
|
||||
|
||||
В `_monitor_agent` для ветки `exit_code == 0` вводим **валидацию результата** перед тем как
|
||||
считать job успешным. Новый защитный хелпер `_validate_result(output_path) -> (ok, reason)`:
|
||||
- лог отсутствует / пустой (size 0 или только whitespace) ⇒ невалиден;
|
||||
- иначе извлекаем result-JSON **тем же контрактом**, что usage-учёт
|
||||
(`usage._extract_last_json_object` / `parse_usage_from_text`); нет валидного объекта ⇒
|
||||
невалиден;
|
||||
- хелпер обёрнут try/except и **не роняет монитор**; при собственной ошибке —
|
||||
fail-safe в сторону провала (TR-3.5).
|
||||
|
||||
`success = (exit_code == 0 and result_ok)`. Побочные эффекты успеха выполняются **только при
|
||||
`success`**:
|
||||
- `_post_usage_comments(...)` (успешный status-коммент) — **не** постится при невалидном
|
||||
результате (AC-12);
|
||||
- `_try_advance_stage(...)` — **не** вызывается при невалидном результате (AC-12);
|
||||
- при `exit_code == 0 and not result_ok` шлётся Telegram-алерт о «пустом/невалидном
|
||||
результате».
|
||||
|
||||
Финализация job (`_finalize_job` получает новый флаг `result_ok`):
|
||||
- `exit_code == 0 and result_ok` ⇒ `done` (как раньше, AC-13 — без регрессии);
|
||||
- `exit_code != 0` **ИЛИ** `result_ok == False` ⇒ путь провала:
|
||||
- классификация лога `error_classifier.classify_log_file` (по умолчанию **permanent**;
|
||||
transient-маркер уводит в transient-путь — TR-3.3);
|
||||
- permanent: `attempts < max_attempts` ⇒ requeue (`queued`), иначе `failed` + алерт;
|
||||
- `error` информативен: `empty run log / no result JSON (run_id=…)` для случая пустого
|
||||
результата.
|
||||
|
||||
Реальный `exit_code` по-прежнему пишется в `agent_runs` без искажения; на решение
|
||||
done/fail влияет отдельный флаг `result_ok`, а не подменённый код выхода.
|
||||
|
||||
`exit_code == 0` теперь **всегда** завершается терминально/ретраябельно (`done` |
|
||||
`failed` | `queued`) — путь «быстрая смерть с exit 0 → вечный running» закрыт (AC-14, BR-3).
|
||||
Watchdog ORCH-7 продолжает закрывать таймауты.
|
||||
|
||||
### Конфигурация (config.py)
|
||||
|
||||
| Настройка | Env | Default | Назначение |
|
||||
|-----------|-----|---------|------------|
|
||||
| `preflight_check_auth` | `ORCH_PREFLIGHT_CHECK_AUTH` | `True` | Вкл/выкл auth-проверку (аварийный тумблер) |
|
||||
| `claude_credentials_path` | `ORCH_CLAUDE_CREDENTIALS_PATH` | `""` | Явный путь; пусто ⇒ `<AGENT_HOME>/.claude/.credentials.json` |
|
||||
| `auth_expiry_skew_seconds` | `ORCH_AUTH_EXPIRY_SKEW_SECONDS` | `0` | Запас на рассинхрон часов при сравнении `expiresAt` |
|
||||
|
||||
`agent_effort_*` дефолты и `--effort` в `_spawn` — **не трогаем** (scope, ORCH-50).
|
||||
|
||||
## Альтернативы
|
||||
|
||||
- **A1. Prompt-ping (ping→pong) для проверки auth.** ❌ Запрещено BR-1 (жжёт rate limit,
|
||||
латентность). Отвергнуто.
|
||||
- **A2. Только постфактум-маркер (чистый вариант b).** Ловит auth лишь ПОСЛЕ спавна и траты
|
||||
цикла; не гейтирует клейм. Оставлен как защитная сетка, но не как основной механизм.
|
||||
- **A3. Сетевая валидация токена у провайдера.** Нарушает «preflight локальный» (TR-1.6),
|
||||
добавляет сетевую зависимость в горячий путь воркера. Отвергнуто.
|
||||
- **A4. Подменять exit_code на ненулевой при пустом результате.** Исказило бы
|
||||
`agent_runs.exit_code` и классификацию. Выбрали отдельный флаг `result_ok`.
|
||||
- **A5. Отдельный кеш для auth.** Избыточно — `_compute()` уже под общим TTL.
|
||||
|
||||
## Последствия
|
||||
|
||||
**Плюсы.**
|
||||
- Заведомо нерабочая (не залогинен / протухший токен) задача **не клеймится** — экономия
|
||||
цикла и отсутствие тихого зависания.
|
||||
- Пустая «быстрая смерть» агента теперь видима: `failed`/retry + алерт вместо ложного `done`
|
||||
и движения стадии вперёд по пустому результату.
|
||||
- Без изменения схемы БД, без новых QG/стадий, без новых HTTP-endpoint'ов.
|
||||
- Auth-reason виден в `/queue` для диагностики.
|
||||
|
||||
**Минусы / ограничения.**
|
||||
- **Риск ложноположительного auth-fail** (см. `10-tech-risks.md` R-1): неверно
|
||||
резолвленный путь к credentials заблокирует клейм **всех** проектов (общая очередь).
|
||||
Митигируется: единый источник HOME (`AGENT_HOME`), тумблер `ORCH_PREFLIGHT_CHECK_AUTH`,
|
||||
обязательная проверка на staging (8501) перед прод-деплоем.
|
||||
- Проверка `expiresAt` — локальная; реально отозванный, но ещё не истёкший токен ловится
|
||||
только постфактум-маркером (b).
|
||||
- `expiresAt`-отсутствие трактуется как OK (компромисс против ложных срабатываний).
|
||||
|
||||
**Self-hosting.** Изменения только в слое preflight/launch; **не** требуют рестарта/падения
|
||||
прод-контейнера `orchestrator` в рамках задачи. Выкатка — через staging-гейт (AC-17).
|
||||
|
||||
## Связи
|
||||
|
||||
- BRD `01-brd.md` (P1, P3), ТЗ `02-trz.md` (TR-1.x, TR-3.x; scope-коррекция),
|
||||
Acceptance `03-acceptance-criteria.md` (AC-1…AC-6, AC-10…AC-17).
|
||||
- Риски: `10-tech-risks.md`. Инфра: `07-infra-requirements.md`. БД: `08-data-requirements.md`.
|
||||
- Код: `src/preflight.py`, `src/agents/launcher.py` (`_monitor_agent`, `_finalize_job`),
|
||||
`src/config.py`, `src/usage.py` (`_extract_last_json_object`),
|
||||
`src/error_classifier.py` (`classify_log_file`), `src/queue_worker.py` (без изменений).
|
||||
- ORCH-1 (очередь/resilience), ORCH-7 (watchdog), ORCH-41 (resolver — **не трогаем effort**).
|
||||
- **ORCH-50** — полноценный возврат `--effort` (вынесен из этой задачи).
|
||||
46
docs/work-items/ORCH-044/07-infra-requirements.md
Normal file
46
docs/work-items/ORCH-044/07-infra-requirements.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 07 — Требования к инфраструктуре
|
||||
|
||||
**Work Item:** ORCH-044
|
||||
**Основано на:** ADR-001, ТЗ `02-trz.md`
|
||||
|
||||
## Топология
|
||||
**Без изменений.** Новых контейнеров, портов, сервисов, очередей не вводится. Прод
|
||||
`orchestrator` (8500) и staging `orchestrator-staging` (8501) остаются как есть
|
||||
(`docs/operations/INFRA.md`).
|
||||
|
||||
## Учётные данные claude (P1)
|
||||
- Launcher спавнит claude с `HOME=/home/slin` (`src/agents/launcher.py`). Preflight ДОЛЖЕН
|
||||
резолвить путь к credentials от **этого же** HOME, а не от окружения процесса оркестратора.
|
||||
- Ожидаемое расположение файла OAuth-токена: **`/home/slin/.claude/.credentials.json`**
|
||||
(структура: `claudeAiOauth.expiresAt` — epoch **ms**).
|
||||
- Файл — секрет; в гит НЕ коммитится (правило агентов №8). На хосте монтируется в контейнер
|
||||
как раньше; задача его расположение **не меняет**, только начинает читать.
|
||||
- ⚠️ **Проверить на staging:** реальный путь файла внутри контейнера совпадает с
|
||||
резолвленным preflight. Несовпадение ⇒ ложный auth-fail и блок очереди (R-1).
|
||||
|
||||
## Новые переменные окружения (env-карта)
|
||||
Документировать в `docs/operations/INFRA.md` и docstring `src/config.py`:
|
||||
|
||||
| Env | Default | Назначение |
|
||||
|-----|---------|------------|
|
||||
| `ORCH_PREFLIGHT_CHECK_AUTH` | `true` | Включение token-free auth-проверки в preflight. Аварийный тумблер: `false` возвращает старое поведение (только bin + `--version`). |
|
||||
| `ORCH_CLAUDE_CREDENTIALS_PATH` | `""` | Явный путь к `.credentials.json`. Пусто ⇒ `<AGENT_HOME>/.claude/.credentials.json`. |
|
||||
| `ORCH_AUTH_EXPIRY_SKEW_SECONDS` | `0` | Запас на рассинхрон часов при сравнении `expiresAt`. |
|
||||
|
||||
`--effort` env (`ORCH_AGENT_EFFORT_*`) — **вне scope**; прод-хотфикс `ORCH_AGENT_EFFORT_*=""`
|
||||
в `.env` **оставить как есть** (ORCH-50).
|
||||
|
||||
## Эксплуатационные процедуры
|
||||
- **Аварийный откат auth-гейта без редеплоя кода:** выставить `ORCH_PREFLIGHT_CHECK_AUTH=false`
|
||||
в `.env` и перезапустить воркер обычной процедурой выката (НЕ в рамках этой задачи).
|
||||
- **Диагностика:** auth-причина видна в `GET /queue` (`preflight_reason`) и в warning-логе
|
||||
`orchestrator.preflight`.
|
||||
- **Re-login:** при детекте auth-маркера в логе launcher сбрасывает preflight-кеш, поэтому
|
||||
после ручного `claude /login` следующий тик воркера (≤ `preflight_cache_ttl`) подхватит
|
||||
валидную сессию автоматически.
|
||||
|
||||
## Self-hosting / деплой (AC-17)
|
||||
- Изменения только в слое preflight/launch — **не** требуют рестарта/падения прод-контейнера
|
||||
в рамках задачи.
|
||||
- Выкатка self-доработки ORCH — **через staging-гейт (8501)** перед прод-деплоем
|
||||
(CLAUDE.md, `docs/operations/INFRA.md`, ADR-0003).
|
||||
23
docs/work-items/ORCH-044/08-data-requirements.md
Normal file
23
docs/work-items/ORCH-044/08-data-requirements.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# 08 — Требования к схеме БД
|
||||
|
||||
**Work Item:** ORCH-044
|
||||
**Основано на:** ADR-001, ТЗ `02-trz.md` §4
|
||||
|
||||
## Вердикт: изменений схемы НЕ требуется
|
||||
|
||||
Новых таблиц, колонок, индексов, миграций — **нет**.
|
||||
|
||||
P1 (auth-preflight) и P3 (пустой результат ⇒ провал) работают на **существующих** структурах:
|
||||
|
||||
- **`jobs`** — повторно используются существующие колонки для пути провала:
|
||||
`status` (`queued`/`running`/`done`/`failed`), `error`, `attempts`, `max_attempts`,
|
||||
`transient_attempts`, `available_at`, `run_id`. Пустой/невалидный результат идёт тем же
|
||||
путём, что и обычный permanent/transient провал (`mark_job` / `mark_job_transient`).
|
||||
- **`agent_runs`** — `exit_code` пишется без искажения (реальный код выхода процесса).
|
||||
Решение done/fail принимается по отдельному in-memory флагу `result_ok` в `_monitor_agent`,
|
||||
а не по колонке.
|
||||
|
||||
## Состояние данных
|
||||
- Никаких бэкофиллов / data-migration.
|
||||
- Auth-проверка читает **файл** `.credentials.json` (вне БД), результат кешируется in-memory
|
||||
(`preflight._cache`), не персистится.
|
||||
20
docs/work-items/ORCH-044/10-tech-risks.md
Normal file
20
docs/work-items/ORCH-044/10-tech-risks.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 10 — Технические риски
|
||||
|
||||
**Work Item:** ORCH-044
|
||||
**Основано на:** ADR-001
|
||||
|
||||
| ID | Риск | Вероятн. | Влияние | Митигация |
|
||||
|----|------|----------|---------|-----------|
|
||||
| R-1 | **Ложноположительный auth-fail.** Неверно резолвленный путь к `.credentials.json` (иной HOME/маунт) ⇒ preflight всегда FAIL ⇒ **не клеймится ни одна job всех проектов** (общая очередь, self-hosting). | Средняя | **Высокое** | Единый источник HOME (`AgentLauncher.AGENT_HOME`, зеркально `_claude_bin()`); тумблер `ORCH_PREFLIGHT_CHECK_AUTH=false`; **обязательная проверка на staging** (реальный путь == резолвленный) перед прод-деплоем; информативный reason в `/queue` + warning-лог. |
|
||||
| R-2 | **Fail-safe-провал на легитимном пустом выводе.** Агент легитимно завершился `exit 0` с непустым логом, но `_validate_result` ошибочно счёл результат невалидным ⇒ ложный `failed`/requeue (регрессия AC-13). | Низкая | Среднее | Контракт извлечения JSON — тот же, что у работающего usage-учёта (`_extract_last_json_object`); регресс-тест TC-15 (валидный лог ⇒ `done`); валидатор не трогает успешный путь, кроме булева флага. |
|
||||
| R-3 | **`expiresAt` без сетевой валидации.** Реально отозванный, но ещё не истёкший по времени токен пройдёт проактивную проверку (a). | Средняя | Среднее | Защитная сетка постфактум (b): маркер `Not logged in` в логе ⇒ `error` + `preflight.reset_cache()` ⇒ следующий тик переоценивает auth; полная сетевая валидация — вне scope (BR-1). |
|
||||
| R-4 | **`expiresAt` отсутствует/нечисловой** в файле (иная версия CLI / иной формат) ⇒ проверка трактует как OK и пропускает. | Низкая | Низкое | Осознанный компромисс против ложных срабатываний (см. ADR §P1.5); отсутствие токена/accessToken по-прежнему ⇒ FAIL; постфактум-маркер ловит реальный «не залогинен». |
|
||||
| R-5 | **Часовой рассинхрон** контейнер vs токен ⇒ валидный токен сочтён истёкшим. | Низкая | Среднее | `ORCH_AUTH_EXPIRY_SKEW_SECONDS` (default 0) для запаса; контейнеры на одном хосте (mva154) — рассинхрон маловероятен. |
|
||||
| R-6 | **Транзиентный auth (битый JSON в момент записи re-login).** Чтение файла во время атомарной перезаписи ⇒ временный FAIL. | Низкая | Низкое | Кеш TTL сглаживает; следующий тик перечитает; fail-safe в сторону «подождать» (job остаётся `queued`, не теряется). |
|
||||
| R-7 | **Конфликт test-plan с коррекцией scope.** `04-test-plan.yaml` TC-09/TC-10/TC-11 проверяют `--effort` (variant B: «`--effort` не формируется»), но владелец **исключил** effort из ORCH-044 и оставил дефолты `agent_effort_*` = `high`. При дефолтной тест-конфигурации `_spawn` сформирует `--effort high` ⇒ TC-09 (ожидающий отсутствие флага) **упадёт**. | **Высокая** | Среднее | Developer/Tester: **адаптировать TC-09/10/11** под «effort не трогаем» (assert успешной сборки cmd без требования удаления флага, либо пометить как deferred→ORCH-50). Артефакт `04-test-plan.yaml` — чужой этап (правило №3), архитектор его НЕ редактирует, только фиксирует расхождение здесь. AC-7/AC-8/AC-9 не применяются (см. `03-acceptance-criteria.md` §P2). |
|
||||
| R-8 | **Постфактум auth-сброс кеша зацикливает.** Повторные auth-провалы ⇒ повторные `reset_cache()`. | Низкая | Низкое | `reset_cache()` лишь форсирует один пересчёт; следующий `check()` снова закеширует на TTL; цикла «горячего» чтения нет; job не клеймится при FAIL. |
|
||||
|
||||
## Сводно
|
||||
Доминирующий риск — **R-1** (блок очереди ложным auth-fail при неверном пути) и
|
||||
организационный **R-7** (test-plan vs scope). Оба закрываются: R-1 — staging-проверкой +
|
||||
тумблером, R-7 — правкой effort-тестов разработчиком/тестером согласно коррекции владельца.
|
||||
67
docs/work-items/ORCH-044/12-review.md
Normal file
67
docs/work-items/ORCH-044/12-review.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-044
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-044
|
||||
|
||||
## Summary
|
||||
PR закрывает две системные дыры слоя запуска агента (инцидент ORCH-17): **P1** — token-free
|
||||
auth-гейт в preflight, **P3** — «пустой лог / нет result-JSON ⇒ провал». **P2 (`--effort`)
|
||||
корректно исключён** из scope владельцем и вынесен в ORCH-50 — код effort (`_spawn`,
|
||||
`resolve_agent_effort`, `agent_effort_*`) не тронут, что соответствует коррекции в 02-trz.md
|
||||
и ADR-001.
|
||||
|
||||
Реализация полностью соответствует ТЗ и ADR-001. Документация обновлена в том же PR
|
||||
(README.md, internals.md, INFRA.md, CHANGELOG.md, ADR заведён). Тесты зелёные
|
||||
(`pytest tests/ -q` → 504 passed; новые `test_preflight_auth.py` + `test_empty_log_failure.py`
|
||||
покрывают AC-1…AC-6, AC-10…AC-14). Verdict: **APPROVED**.
|
||||
|
||||
## Соответствие ТЗ / AC
|
||||
- **P1 (TR-1.1…1.7):** `preflight._check_auth()` — чтение `<AGENT_HOME>/.credentials.json`,
|
||||
валидация `claudeAiOauth.accessToken` + `expiresAt` (epoch ms, skew), never-raise fail-safe.
|
||||
Путь резолвится от `AgentLauncher.AGENT_HOME` (новый `_agent_home()`, зеркально `_claude_bin()`),
|
||||
а не от HOME процесса орка (TR-1.3 ✓). Встроено в кешируемый `_compute()` (TR-1.4 ✓).
|
||||
Гейтинг клейма не требовал правок `_drain_once` (TR-1.5 ✓ — подтверждено
|
||||
`test_worker_does_not_claim_when_auth_fails`). AC-1/2/3/4/5/6 покрыты тестами.
|
||||
- **P3 (TR-3.1…3.5):** `_validate_result()` (лог непустой + trailing result-JSON по контракту
|
||||
`usage._extract_last_json_object`), `success = exit 0 AND result_ok`. Побочные эффекты успеха
|
||||
(`_post_usage_comments`, `_try_advance_stage`) выполняются только при `success`; при пустом
|
||||
результате — Telegram-алерт + маршрутизация в провал через `_finalize_job(result_ok=False)`.
|
||||
Реальный `exit_code` пишется в `agent_runs` без искажения (отдельный флаг — A4 из ADR).
|
||||
AC-10/11/12/13/14 покрыты тестами (включая `test_never_running_after_empty_result`,
|
||||
permanent/transient-классификацию).
|
||||
- **P1b защитная сетка:** `_handle_auth_marker()` + `is_auth_failure_text()` сбрасывают
|
||||
preflight-кеш при маркере разлогина в логе пути провала; не transient, breaker не крутится.
|
||||
|
||||
## Соответствие ADR
|
||||
Реализация дословно следует ADR-001 (§P1 шаги 1–6, §P3 валидация + finalize, §Конфигурация:
|
||||
`preflight_check_auth`/`claude_credentials_path`/`auth_expiry_skew_seconds`). Альтернативы A4/A5
|
||||
отражены в коде (отдельный `result_ok` вместо подмены exit_code; общий TTL вместо отдельного
|
||||
кеша). Verified: `usage._extract_last_json_object` и `preflight.reset_cache` существуют.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- нет
|
||||
|
||||
### P1 — Must fix
|
||||
- нет
|
||||
|
||||
### P2 — Should fix
|
||||
- нет (опционально: PydanticDeprecation warning в `config.py:4` — предсуществующий, вне scope ORCH-044).
|
||||
|
||||
## Документация
|
||||
Обновлена корректно и в том же PR (правило агентов №2/№6, AC-15):
|
||||
- `docs/architecture/README.md` — описание Preflight (auth) и Agent Launcher (валидация результата);
|
||||
- `docs/architecture/internals.md` — §4 «Валидация результата», постфактум auth-детекция, таблица resilience, диаграмма `_finalize_job(result_ok)`;
|
||||
- `docs/operations/INFRA.md` — env-карта (3 новые настройки) + раздел «Preflight auth-гейт» с риском R-1;
|
||||
- `CHANGELOG.md` — запись `[Unreleased] / Added`;
|
||||
- ADR `06-adr/ADR-001-preflight-auth-and-empty-result-failure.md` заведён; `10-tech-risks.md` присутствует.
|
||||
|
||||
## Self-hosting (AC-17)
|
||||
Изменения только в слое preflight/launch — не требуют рестарта прод-контейнера в рамках задачи.
|
||||
Выкатка через обязательный staging-гейт (8501) перед прод. Риск ложноположительного auth-fail
|
||||
(R-1) митигирован тумблером `ORCH_PREFLIGHT_CHECK_AUTH` и проверкой на staging.
|
||||
84
docs/work-items/ORCH-044/13-test-report.md
Normal file
84
docs/work-items/ORCH-044/13-test-report.md
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-044
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-044
|
||||
|
||||
Надёжность запуска агента: preflight auth (P1) + пустой лог = провал (P3).
|
||||
**P2 (`--effort`) исключён из scope владельцем** (06.06) — вынесен в ORCH-50;
|
||||
AC-7/AC-8/AC-9 и TC-09/TC-11 (effort) в этой задаче **не применяются (N/A)**.
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Branch: feature/ORCH-044-preflight-auth-effort
|
||||
- Дата: 2026-06-06T08:39Z
|
||||
- Прод-инстанс (8500): не трогался; smoke — read-only GET.
|
||||
|
||||
## Результаты — Quality Gate тесты (04-test-plan.yaml)
|
||||
|
||||
| TC ID | Описание | Тест(ы) | Результат |
|
||||
|-------|----------|---------|-----------|
|
||||
| TC-01 | Нет `.credentials.json` ⇒ FAIL про auth | `test_missing_credentials_fails` | PASS |
|
||||
| TC-02 | Протухший OAuth `expiresAt` ⇒ FAIL | `test_expired_token_fails` | PASS |
|
||||
| TC-03 | Валидный логин ⇒ OK без регрессии | `test_valid_login_ok` | PASS |
|
||||
| TC-04 | Битый JSON ⇒ FAIL без исключения | `test_broken_json_fails_without_raising` | PASS |
|
||||
| TC-05 | Token-free: нет сетевого вызова | `test_auth_check_makes_no_network_call` | PASS |
|
||||
| TC-06 | Кеширование auth в пределах TTL | `test_auth_result_cached_within_ttl` | PASS |
|
||||
| TC-07 | Путь credentials от HOME агента (/home/slin) | `test_credentials_path_follows_agent_home` | PASS |
|
||||
| TC-08 | Worker не клеймит job при auth-fail | `test_worker_does_not_claim_when_auth_fails` | PASS |
|
||||
| TC-09 | (effort) cmd без `--effort` | `test_effort_flag.py` | N/A — scope исключён владельцем (ORCH-50) |
|
||||
| TC-10 | `resolve_agent_effort` согласован | `test_resolve_agent_effort.py` (11 тестов) | PASS — effort не тронут, тесты зелёные |
|
||||
| TC-11 | (effort) дефолтный путь даёт непустой JSON | `test_effort_flag.py` | N/A — scope исключён владельцем (ORCH-50) |
|
||||
| TC-12 | Пустой лог + exit0 ⇒ failed + алерт | `test_empty_log_exit0_terminal_failed_alerts` | PASS |
|
||||
| TC-13 | Лог без result-JSON ⇒ провал | `test_garbage_log_exit0_not_done` | PASS |
|
||||
| TC-14 | Провал ⇒ нет advance/успешного коммента | `test_empty_result_suppresses_advance_and_comment` | PASS |
|
||||
| TC-15 | Валидный JSON ⇒ done без регрессии | `test_valid_result_done`, `test_success_advances_and_comments` | PASS |
|
||||
| TC-16 | Никогда не вечный `running` | `test_never_running_after_empty_result` | PASS |
|
||||
| TC-17 | Классификация permanent/transient | `test_empty_result_defaults_permanent`, `..._with_transient_marker_goes_transient` | PASS |
|
||||
| TC-18 | Регресс preflight (bin/version) | `test_resilience.py::TestPreflight` | PASS |
|
||||
| TC-19 | Полный `pytest tests/` зелёный | вся сюита | PASS (504 passed) |
|
||||
|
||||
Дополнительно покрыто (вне нумерации плана): постфактум auth-маркер
|
||||
(`test_is_auth_failure_text_*`, `TestAuthMarkerHandling`), тумблер
|
||||
`ORCH_PREFLIGHT_CHECK_AUTH` (`test_auth_toggle_off_skips_check`), явный путь
|
||||
credentials (`test_explicit_credentials_path_wins`).
|
||||
|
||||
## Сопоставление с критериями приёмки
|
||||
- **AC-1…AC-6** (preflight auth): PASS — TC-01…TC-08.
|
||||
- **AC-7/AC-8/AC-9** (effort): N/A — исключены владельцем (см. 02-trz.md, 03-acceptance-criteria.md).
|
||||
- **AC-10…AC-14** (пустой результат ⇒ провал): PASS — TC-12…TC-16.
|
||||
- **AC-15** (документация в том же PR): PASS — подтверждено review (APPROVED): README/internals/INFRA/CHANGELOG/ADR обновлены.
|
||||
- **AC-16** (тесты зелёные): PASS — 504 passed.
|
||||
- **AC-17** (self-hosting): PASS — изменения в слое preflight/launch; прод-контейнер не рестартовался; smoke 8500 read-only.
|
||||
|
||||
## Smoke test API (8500, read-only GET)
|
||||
| Endpoint | Код | Замечание |
|
||||
|----------|-----|-----------|
|
||||
| GET /health | 200 | `{"status":"ok","service":"orchestrator"}` |
|
||||
| GET /status | 200 | активна задача ORCH-044 (stage=testing) |
|
||||
| GET /queue | 200 | counts ok (failed=0), `preflight_ok=true`, breaker=closed |
|
||||
|
||||
> curl в окружении отсутствует — smoke выполнен через `urllib` (эквивалентные GET).
|
||||
|
||||
## Вывод pytest
|
||||
```
|
||||
======================= 504 passed, 1 warning in 10.82s ========================
|
||||
```
|
||||
Модули плана (детально):
|
||||
```
|
||||
tests/test_preflight_auth.py ......... 18 passed
|
||||
tests/test_resolve_agent_effort.py ... 11 passed
|
||||
tests/test_empty_log_failure.py ...... 18 passed
|
||||
tests/test_resilience.py ............. 31 passed
|
||||
(итого по модулям плана: 78 passed)
|
||||
```
|
||||
Warning: `PydanticDeprecatedSince20` в `src/config.py:4` — предсуществующий,
|
||||
вне scope ORCH-044 (зафиксировано в review как P2/опционально).
|
||||
|
||||
## Итог
|
||||
**PASS** — все применимые тесты плана зелёные, существующая сюита не сломана,
|
||||
smoke API исправен. TC-09/TC-11 (effort) корректно N/A: P2 исключён владельцем
|
||||
и вынесен в ORCH-50. Задача готова к стадии **deploy-staging**.
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-06T08:41:49Z
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed. All checks passed (10/10).
|
||||
|
||||
- Work item: ORCH-044
|
||||
- Repo: orchestrator (self-hosting → staging gate is real, not a no-op)
|
||||
- Container: `orchestrator-staging` (port 8501)
|
||||
- Command (canonical, ran INSIDE the container so B6 reads the instance's own `.env.staging` process-env):
|
||||
`python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`
|
||||
- Exit code: 0
|
||||
|
||||
## Results
|
||||
|
||||
```
|
||||
[Block A] SMOKE
|
||||
✓ PASS A1 GET /health → 200 status=ok
|
||||
✓ PASS A2 GET /queue → 200 with counts/max_concurrency/resilience
|
||||
✓ PASS A3 ORCH_STAGING=true (not prod)
|
||||
|
||||
[Block B] ACCESS
|
||||
✓ PASS B4 Plane: sandbox project accessible
|
||||
✓ PASS B5 Gitea: orchestrator-sandbox accessible, push=true
|
||||
✓ PASS B6 Registry: sandbox present, prod ET/ORCH absent
|
||||
|
||||
[Block C] E2E (mode=stub)
|
||||
✓ PASS C7 Create issue in Plane SANDBOX
|
||||
✓ PASS C8 Trigger pipeline via /webhook/plane
|
||||
✓ PASS C9a Branch appears in orchestrator-sandbox
|
||||
✓ PASS C9b Analyst job enqueued in staging queue
|
||||
|
||||
[CLEANUP]
|
||||
✓ PASS CLEANUP: deleted branch in orchestrator-sandbox
|
||||
✓ PASS CLEANUP: deleted Plane issue
|
||||
✓ PASS CLEANUP DB: deleted job + task rows
|
||||
|
||||
RESULT: 10/10 checks PASS
|
||||
```
|
||||
|
||||
> Note: the host in this environment lacks the `docker` CLI, so the canonical
|
||||
> `docker exec orchestrator-staging ...` was performed via the Docker Engine API
|
||||
> over `/var/run/docker.sock` (Python stdlib, no host-env leakage). Semantics are
|
||||
> identical to `docker exec`: the script ran inside `orchestrator-staging` with
|
||||
> its own `.env.staging` process-env, keeping the B6 registry-isolation check valid.
|
||||
@@ -185,6 +185,10 @@ class AgentLauncher:
|
||||
}
|
||||
|
||||
CLAUDE_BIN = "/opt/claude-code/bin/claude.exe"
|
||||
# ORCH-044 (P1): HOME the claude subprocess actually runs under. preflight
|
||||
# resolves the OAuth credentials path from this (NOT the orchestrator process
|
||||
# HOME), so keep this single source of truth in sync with the spawn env below.
|
||||
AGENT_HOME = "/home/slin"
|
||||
# ORCH-7 (M-2): timeout is now configurable. AGENT_TIMEOUT stays as a
|
||||
# backward-compatible alias for the default; the actual value (and per-agent
|
||||
# overrides) live in settings and are resolved via _resolve_timeout().
|
||||
@@ -323,7 +327,7 @@ class AgentLauncher:
|
||||
stderr=subprocess.STDOUT,
|
||||
env={
|
||||
**os.environ,
|
||||
"HOME": "/home/slin",
|
||||
"HOME": self.AGENT_HOME,
|
||||
"GIT_AUTHOR_NAME": "claude-bot",
|
||||
"GIT_AUTHOR_EMAIL": "claude-bot@mva154.local",
|
||||
"GIT_COMMITTER_NAME": "claude-bot",
|
||||
@@ -492,6 +496,21 @@ class AgentLauncher:
|
||||
|
||||
notify_agent_finished(run_id, agent, exit_code, task_id=_task_id, duration_s=_duration_s)
|
||||
|
||||
# ORCH-044 (P3): a clean exit_code==0 is NOT enough — claude can die fast
|
||||
# (logged out, killed flag) leaving an empty / JSON-less log while still
|
||||
# exiting 0. Validate the result; only (exit 0 AND result_ok) is success.
|
||||
# The real exit_code is still recorded above without distortion; this flag
|
||||
# drives the done/fail decision (ADR-001 §P3 / A4).
|
||||
result_ok, result_reason = (True, "ok")
|
||||
if exit_code == 0:
|
||||
result_ok, result_reason = self._validate_result(output_path)
|
||||
if not result_ok:
|
||||
logger.warning(
|
||||
f"Agent run_id={run_id} ({agent}) exited 0 but result invalid: "
|
||||
f"{result_reason}"
|
||||
)
|
||||
success = (exit_code == 0 and result_ok)
|
||||
|
||||
# Feature 4: parse token usage / cost from the (json) run log and record
|
||||
# it on the agent_runs row. Never fatal — a garbled/missing JSON records
|
||||
# NULLs and logs a warning so a broken run can't crash the monitor.
|
||||
@@ -510,7 +529,7 @@ class AgentLauncher:
|
||||
try:
|
||||
git_env = {
|
||||
**os.environ,
|
||||
"HOME": "/home/slin",
|
||||
"HOME": self.AGENT_HOME,
|
||||
"GIT_AUTHOR_NAME": "claude-bot",
|
||||
"GIT_AUTHOR_EMAIL": "claude-bot@mva154.local",
|
||||
"GIT_COMMITTER_NAME": "claude-bot",
|
||||
@@ -593,11 +612,34 @@ class AgentLauncher:
|
||||
from ..notifications import send_telegram
|
||||
send_telegram(f"\u26a0\ufe0f {_wid}: Agent {agent} failed (exit_code={exit_code}). Check logs: /app/data/runs/{run_id}.log")
|
||||
|
||||
# ORCH-044 (P3): exit 0 with an empty/invalid result is a failure, not a
|
||||
# success — alert (like other failures) and DO NOT post a success comment
|
||||
# or advance the stage. The job-queue finalize below routes it to
|
||||
# failed/retry. (AC-10/11/12.)
|
||||
if exit_code == 0 and not success:
|
||||
try:
|
||||
conn = get_db()
|
||||
task_row = conn.execute(
|
||||
"SELECT work_item_id FROM tasks WHERE repo=? AND branch=?",
|
||||
(repo, branch),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
_wid = task_row[0] if task_row else None
|
||||
from ..notifications import send_telegram
|
||||
send_telegram(
|
||||
f"⚠️ {_wid or repo}: Agent {agent} exited 0 but produced "
|
||||
f"an empty/invalid result ({result_reason}). "
|
||||
f"Logs: /app/data/runs/{run_id}.log"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"run_id={run_id}: empty-result alert failed: {e}")
|
||||
|
||||
# Feature 4 + ORCH-016: post the unified per-agent status comment under
|
||||
# that agent's bot, threading the wall-clock duration we just measured
|
||||
# straight through (ADR-001 §6: explicit param wins over DB fallback).
|
||||
# The deployer finishing the task also posts the per-task usage summary.
|
||||
if exit_code == 0:
|
||||
# ORCH-044 (P3): only on real success (exit 0 AND valid result).
|
||||
if success:
|
||||
try:
|
||||
self._post_usage_comments(
|
||||
run_id, agent, repo, branch, _usage, duration_s=_duration_s
|
||||
@@ -605,14 +647,81 @@ class AgentLauncher:
|
||||
except Exception as e:
|
||||
logger.warning(f"run_id={run_id}: usage comment failed: {e}")
|
||||
|
||||
# Auto-advance stage if agent finished successfully and QG passes
|
||||
if exit_code == 0:
|
||||
# Auto-advance stage if agent finished successfully and QG passes.
|
||||
# ORCH-044 (P3): suppressed when the result was empty/invalid.
|
||||
if success:
|
||||
self._try_advance_stage(run_id, agent, repo, branch)
|
||||
|
||||
# ORCH-1: drive the job-queue status for queue-launched jobs only.
|
||||
# (Legacy direct launch() has job_id=None and is unaffected.)
|
||||
# ORCH-044 (P3): result_ok lets _finalize_job treat an empty-result exit 0
|
||||
# as a failure rather than 'done'.
|
||||
if job_id is not None:
|
||||
self._finalize_job(job_id, agent, run_id, exit_code, output_path=output_path)
|
||||
self._finalize_job(
|
||||
job_id, agent, run_id, exit_code,
|
||||
output_path=output_path, result_ok=result_ok,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_result(output_path) -> tuple[bool, str]:
|
||||
"""ORCH-044 (P3): is the run log a real result, or an empty/JSON-less death?
|
||||
|
||||
Returns (ok, reason). A run counts as a valid result only when the log
|
||||
exists, is non-empty (not just whitespace), AND carries a parseable
|
||||
trailing result-JSON object — the same contract usage accounting uses
|
||||
(usage._extract_last_json_object). claude --output-format json always
|
||||
emits exactly such an object on a real run, so its absence means the agent
|
||||
died before producing anything.
|
||||
|
||||
Never raises: any error is treated as an invalid result (fail-safe toward
|
||||
failing the job rather than silently passing — TR-3.5).
|
||||
"""
|
||||
try:
|
||||
if not output_path:
|
||||
return False, "no output path"
|
||||
if not os.path.exists(output_path):
|
||||
return False, "run log missing"
|
||||
if os.path.getsize(output_path) == 0:
|
||||
return False, "empty run log (0 bytes)"
|
||||
with open(output_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
text = f.read()
|
||||
if not text.strip():
|
||||
return False, "empty run log (whitespace only)"
|
||||
from ..usage import _extract_last_json_object
|
||||
if _extract_last_json_object(text) is None:
|
||||
return False, "no result JSON in run log"
|
||||
return True, "result ok"
|
||||
except Exception as e: # pragma: no cover - defensive fail-safe
|
||||
return False, f"result validation error: {e}"
|
||||
|
||||
def _handle_auth_marker(self, log_path) -> bool:
|
||||
"""ORCH-044 (P1b): post-factum auth-failure detection (defensive net).
|
||||
|
||||
If an agent died because the session was logged out / expired between
|
||||
preflight and spawn, reset the preflight cache so the NEXT worker tick
|
||||
re-evaluates auth proactively (fast re-login pickup, or continued gating
|
||||
if still broken). Auth failure is deliberately NOT treated as transient
|
||||
and does NOT crank the circuit breaker — preflight is the right gate here.
|
||||
Returns True if an auth marker was found. Never raises.
|
||||
"""
|
||||
try:
|
||||
from .. import preflight
|
||||
with open(log_path, "rb") as f:
|
||||
try:
|
||||
f.seek(-16384, 2)
|
||||
except OSError:
|
||||
f.seek(0)
|
||||
text = f.read().decode("utf-8", errors="replace")
|
||||
if preflight.is_auth_failure_text(text):
|
||||
logger.warning(
|
||||
f"Auth-failure marker in {log_path}; resetting preflight cache "
|
||||
f"so the next tick re-checks auth"
|
||||
)
|
||||
preflight.reset_cache()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _backoff_seconds(self, transient_attempts: int, retry_after: int = None) -> int:
|
||||
"""Exponential backoff for transient failures, honouring Retry-After.
|
||||
@@ -627,17 +736,21 @@ class AgentLauncher:
|
||||
backoff = max(backoff, min(retry_after, cap))
|
||||
return int(backoff)
|
||||
|
||||
def _finalize_job(self, job_id: int, agent: str, run_id: int, exit_code, output_path=None):
|
||||
def _finalize_job(self, job_id: int, agent: str, run_id: int, exit_code,
|
||||
output_path=None, result_ok: bool = True):
|
||||
"""ORCH-1: update the jobs row after the agent process finished.
|
||||
|
||||
exit_code == 0 -> done (and resets the breaker streak via on_outcome).
|
||||
exit_code != 0 -> classify the failure from the run log tail (token-free):
|
||||
success = (exit_code == 0 AND result_ok) -> done (resets the breaker
|
||||
streak via on_outcome). ORCH-044 (P3): result_ok==False means
|
||||
exit 0 but the run log was empty / had no result-JSON, so it is
|
||||
routed through the failure path below, NOT marked done.
|
||||
otherwise -> classify the failure from the run log tail (token-free):
|
||||
- TRANSIENT (429/overload/network): backoff-requeue with available_at in
|
||||
the future + a SEPARATE transient_attempts budget
|
||||
(settings.transient_max_attempts), honouring Retry-After. Reported to
|
||||
the breaker so it opens after N consecutive transient failures.
|
||||
- PERMANENT (code fault): ordinary attempts < max_attempts requeue,
|
||||
otherwise 'failed' + Telegram.
|
||||
- PERMANENT (code fault, incl. the empty-result case): ordinary
|
||||
attempts < max_attempts requeue, otherwise 'failed' + Telegram.
|
||||
"""
|
||||
from ..db import get_job, mark_job
|
||||
from ..error_classifier import classify_log_file
|
||||
@@ -645,34 +758,55 @@ class AgentLauncher:
|
||||
job = get_job(job_id)
|
||||
if not job:
|
||||
return
|
||||
if exit_code == 0:
|
||||
if exit_code == 0 and result_ok:
|
||||
mark_job(job_id, "done", run_id=run_id)
|
||||
logger.info(f"Job {job_id} ({agent}) done (run_id={run_id})")
|
||||
self._record_outcome(transient=False, recovered=True)
|
||||
return
|
||||
|
||||
log_path = output_path or f"/app/data/runs/{run_id}.log"
|
||||
|
||||
# ORCH-044 (P1b): if the failure was an auth death, invalidate the
|
||||
# preflight cache so the next tick re-gates on auth proactively.
|
||||
self._handle_auth_marker(log_path)
|
||||
|
||||
# ORCH-044 (P3): informative error for the empty/invalid-result case
|
||||
# (exit 0 but no usable result). Defaults to permanent (it is not a
|
||||
# 429/overload) unless the log carries a transient marker (TR-3.3).
|
||||
empty_result = (exit_code == 0 and not result_ok)
|
||||
override_err = (
|
||||
f"empty run log / no result JSON (run_id={run_id})"
|
||||
if empty_result else None
|
||||
)
|
||||
|
||||
# Classify the failure from the agent log tail (no token cost).
|
||||
kind, retry_after = "permanent", None
|
||||
log_path = output_path or f"/app/data/runs/{run_id}.log"
|
||||
try:
|
||||
kind, retry_after = classify_log_file(log_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if kind == "transient":
|
||||
self._finalize_transient(job_id, agent, run_id, exit_code, job, retry_after)
|
||||
self._finalize_transient(job_id, agent, run_id, exit_code, job,
|
||||
retry_after, error=override_err)
|
||||
else:
|
||||
self._finalize_permanent(job_id, agent, run_id, exit_code, job)
|
||||
self._finalize_permanent(job_id, agent, run_id, exit_code, job,
|
||||
error=override_err)
|
||||
except Exception as e:
|
||||
logger.error(f"Job {job_id}: _finalize_job error: {e}")
|
||||
|
||||
def _finalize_transient(self, job_id, agent, run_id, exit_code, job, retry_after):
|
||||
"""Transient (429/overload/net) failure -> backoff requeue or fail when budget out."""
|
||||
def _finalize_transient(self, job_id, agent, run_id, exit_code, job, retry_after,
|
||||
error: str | None = None):
|
||||
"""Transient (429/overload/net) failure -> backoff requeue or fail when budget out.
|
||||
|
||||
ORCH-044 (P3): `error`, when provided, overrides the default transient
|
||||
message (used for the empty-result case so the reason is informative).
|
||||
"""
|
||||
from ..db import mark_job, mark_job_transient
|
||||
tattempts = job.get("transient_attempts", 0)
|
||||
tmax = settings.transient_max_attempts
|
||||
err = (f"transient (429/overload) agent {agent} exit={exit_code} "
|
||||
f"(run_id={run_id}); retry_after={retry_after}")
|
||||
err = error or (f"transient (429/overload) agent {agent} exit={exit_code} "
|
||||
f"(run_id={run_id}); retry_after={retry_after}")
|
||||
self._record_outcome(transient=True, recovered=False)
|
||||
if tattempts < tmax:
|
||||
backoff = self._backoff_seconds(tattempts + 1, retry_after)
|
||||
@@ -689,12 +823,17 @@ class AgentLauncher:
|
||||
self._notify_failed(job_id, agent, job, run_id,
|
||||
f"transient (rate-limit) after {tattempts} attempts")
|
||||
|
||||
def _finalize_permanent(self, job_id, agent, run_id, exit_code, job):
|
||||
"""Permanent (code-fault) failure -> normal attempts<max requeue, then fail."""
|
||||
def _finalize_permanent(self, job_id, agent, run_id, exit_code, job,
|
||||
error: str | None = None):
|
||||
"""Permanent (code-fault) failure -> normal attempts<max requeue, then fail.
|
||||
|
||||
ORCH-044 (P3): `error`, when provided, overrides the default message
|
||||
(used for the empty-result case, e.g. "empty run log / no result JSON").
|
||||
"""
|
||||
from ..db import mark_job
|
||||
attempts = job.get("attempts", 0)
|
||||
max_attempts = job.get("max_attempts", 2)
|
||||
err = f"agent {agent} exit_code={exit_code} (run_id={run_id})"
|
||||
err = error or f"agent {agent} exit_code={exit_code} (run_id={run_id})"
|
||||
self._record_outcome(transient=False, recovered=False)
|
||||
if attempts < max_attempts:
|
||||
mark_job(job_id, "queued", run_id=run_id, error=err)
|
||||
|
||||
@@ -64,6 +64,25 @@ class Settings(BaseSettings):
|
||||
# breaker_threshold -> consecutive transient failures that OPEN the breaker.
|
||||
# breaker_pause_seconds -> how long the breaker stays open before half-open.
|
||||
preflight_cache_ttl: int = 45
|
||||
# ORCH-044 (P1): token-free preflight auth gate. After `claude --version`
|
||||
# succeeds, preflight also checks that claude is logged in by reading the
|
||||
# local OAuth credentials file (no network / no prompt-ping — BR-1).
|
||||
# preflight_check_auth -> master toggle (env ORCH_PREFLIGHT_CHECK_AUTH).
|
||||
# Emergency off-switch if the check ever
|
||||
# false-positives and wedges the shared queue.
|
||||
# claude_credentials_path -> explicit path to .credentials.json
|
||||
# (env ORCH_CLAUDE_CREDENTIALS_PATH). Empty ->
|
||||
# <AGENT_HOME>/.claude/.credentials.json, where
|
||||
# AGENT_HOME is the HOME the launcher really
|
||||
# spawns claude under (/home/slin), NOT the
|
||||
# orchestrator process env.
|
||||
# auth_expiry_skew_seconds -> clock-drift slack when comparing
|
||||
# claudeAiOauth.expiresAt (env
|
||||
# ORCH_AUTH_EXPIRY_SKEW_SECONDS); a token within
|
||||
# this many seconds of now is treated as expired.
|
||||
preflight_check_auth: bool = True
|
||||
claude_credentials_path: str = ""
|
||||
auth_expiry_skew_seconds: int = 0
|
||||
backoff_base_seconds: int = 10
|
||||
backoff_max_seconds: int = 600
|
||||
transient_max_attempts: int = 5
|
||||
@@ -134,14 +153,6 @@ 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,62 +68,6 @@ 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
|
||||
@@ -222,23 +166,19 @@ 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", "Анализ", "analyst"), # Анализ
|
||||
("architecture", "Архитектура", "architect"), # Архитектура
|
||||
("development", "Разработка", "developer"), # Разработка
|
||||
("review", "Код ревью", "reviewer"), # Код ревью
|
||||
("testing", "Тестирование", "tester"), # Тестирование
|
||||
("deploy", "Внедрение", "deployer"), # Внедрение
|
||||
("analysis", "Analysis", "analyst"),
|
||||
("architecture", "Architecture", "architect"),
|
||||
("development", "Development", "developer"),
|
||||
("review", "Review", "reviewer"),
|
||||
("testing", "Testing", "tester"),
|
||||
("deploy", "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.
|
||||
# ORCH-042 (BR-9): "Подтверждение BRD" (was "Ревью БРД").
|
||||
_BRD_LABEL = "Подтверждение BRD"
|
||||
_BRD_LABEL = "\u0420\u0435\u0432\u044c\u044e \u0411\u0420\u0414" # "Ревью БРД"
|
||||
|
||||
_STAGE_ACTIVE_AGENT = {
|
||||
"analysis": "analyst",
|
||||
@@ -292,8 +232,7 @@ 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 '✅/⏸️ Подтверждение BRD <dur> · твоё время[ ⏳]' line between
|
||||
Analysis/Architecture (✅ once the approve-gate passed, ⏸️+⏳ while waiting),
|
||||
- the '⏸️ Ревью БРД <dur> · твоё время[ ⏳]' line between Analysis/Architecture,
|
||||
- a '🔄 <Stage> … идёт' line for the active (in-progress) stage,
|
||||
- the '💰 <in>↓ / <out>↑ · <cost>' totals,
|
||||
- on done: '⏱️ Всего .. · агенты .. · твоё ..' and a '🔗 PR / 📦' line.
|
||||
@@ -426,11 +365,9 @@ 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"\u2705 {brd_label} {dur} \u00b7 \u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f"
|
||||
f"\u23f8\ufe0f {brd_label} {dur} \u00b7 \u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f"
|
||||
)
|
||||
else:
|
||||
# Still waiting on the human (ended not stamped yet).
|
||||
@@ -469,7 +406,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 · 📦 Внедрено' line. Never raises -> None."""
|
||||
"""Build the final '🔗 PR #n · 📦 deployed' line. Never raises -> None."""
|
||||
try:
|
||||
from .config import settings
|
||||
from .db import get_db
|
||||
@@ -499,7 +436,7 @@ def _done_link(task_id: int, work_item_id) -> str | None:
|
||||
parts = []
|
||||
if pr_part:
|
||||
parts.append(pr_part)
|
||||
parts.append("\U0001f4e6 Внедрено") # ORCH-042 (BR-12): was "deployed"
|
||||
parts.append("\U0001f4e6 deployed")
|
||||
return " \u00b7 ".join(parts)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -508,49 +445,19 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
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):
|
||||
|
||||
133
src/preflight.py
133
src/preflight.py
@@ -5,14 +5,25 @@ are reachable WITHOUT spending any tokens. We only do local/cheap checks:
|
||||
|
||||
1. os.path.exists(CLAUDE_BIN) -- instant
|
||||
2. `claude --version` (timeout 5s) -- spawns CLI, does NOT call the API
|
||||
3. auth check (ORCH-044, P1) -- read the local OAuth credentials file
|
||||
|
||||
The result is cached for `preflight_cache_ttl` seconds so we do not re-run
|
||||
`claude --version` on every worker tick.
|
||||
`claude --version` (or re-read the credentials file) on every worker tick.
|
||||
|
||||
🚫 We deliberately do NOT do a prompt ping (ping->pong) — that would burn the
|
||||
rate limit and add latency. Preflight is local-only.
|
||||
|
||||
ORCH-044 (P1): `claude --version` answers successfully even when claude is NOT
|
||||
logged in (the version is local information), so version-only preflight was blind
|
||||
to auth. We add a token-free auth gate: read <AGENT_HOME>/.claude/.credentials.json
|
||||
and validate the OAuth token (presence + expiry). Combined with a post-factum
|
||||
`Not logged in` marker detection (is_auth_failure_text), this stops a logged-out
|
||||
instance from claiming jobs and silently dying with an empty run log. No network
|
||||
call is ever made here.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import subprocess
|
||||
@@ -23,6 +34,15 @@ logger = logging.getLogger("orchestrator.preflight")
|
||||
|
||||
_VERSION_TIMEOUT = 5
|
||||
|
||||
# ORCH-044 (P1b): post-factum auth-failure markers. If an agent started under a
|
||||
# session that died/expired between preflight and spawn, these substrings in the
|
||||
# run log identify the auth failure so the launcher can invalidate the preflight
|
||||
# cache (forcing the next tick to re-evaluate auth proactively).
|
||||
_AUTH_FAIL_RE = re.compile(
|
||||
r"not logged in|please run\s*/login|invalid api key|unauthorized|\b401\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class _PreflightCache:
|
||||
def __init__(self):
|
||||
@@ -74,11 +94,120 @@ def _run_version(bin_path: str) -> tuple[bool, str]:
|
||||
return False, f"--version error: {e}"
|
||||
|
||||
|
||||
def _agent_home() -> str:
|
||||
"""Resolve the HOME the launcher actually spawns claude under (ORCH-044, TR-1.3).
|
||||
|
||||
The auth credentials live under the *agent's* HOME (/home/slin), which the
|
||||
launcher injects into the claude subprocess env — NOT the orchestrator
|
||||
process HOME. We mirror _claude_bin()'s "follow the genuinely executed path"
|
||||
approach by reading AgentLauncher.AGENT_HOME. Falls back to the known default
|
||||
if the launcher cannot be imported (e.g. isolated unit test).
|
||||
"""
|
||||
try:
|
||||
from .agents.launcher import AgentLauncher
|
||||
home = getattr(AgentLauncher, "AGENT_HOME", None)
|
||||
if home:
|
||||
return home
|
||||
except Exception:
|
||||
pass
|
||||
return "/home/slin"
|
||||
|
||||
|
||||
def _credentials_path() -> str:
|
||||
"""Path to claude's OAuth credentials file (ORCH-044, P1).
|
||||
|
||||
settings.claude_credentials_path wins when set; otherwise
|
||||
<AGENT_HOME>/.claude/.credentials.json.
|
||||
"""
|
||||
explicit = (getattr(settings, "claude_credentials_path", "") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
return os.path.join(_agent_home(), ".claude", ".credentials.json")
|
||||
|
||||
|
||||
def _iso(epoch_ms) -> str:
|
||||
"""Best-effort epoch-ms -> ISO-8601 UTC string (for human-readable reasons)."""
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
return datetime.fromtimestamp(int(epoch_ms) / 1000, tz=timezone.utc).isoformat()
|
||||
except Exception:
|
||||
return str(epoch_ms)
|
||||
|
||||
|
||||
def is_auth_failure_text(text: str) -> bool:
|
||||
"""ORCH-044 (P1b): True if `text` contains a claude auth-failure marker.
|
||||
|
||||
Used post-factum on a run log so the launcher can tell an auth death apart
|
||||
from a generic failure and reset the preflight cache. Never raises.
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
try:
|
||||
return bool(_AUTH_FAIL_RE.search(text))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _check_auth() -> tuple[bool, str]:
|
||||
"""ORCH-044 (P1a): token-free local auth gate. Never raises.
|
||||
|
||||
Steps (ADR-001 §P1):
|
||||
1. credentials file missing / unreadable / invalid JSON -> not ok.
|
||||
2. no claudeAiOauth block / accessToken -> not ok.
|
||||
3. claudeAiOauth.expiresAt (epoch ms) <= now + skew -> expired -> not ok.
|
||||
4. accessToken present but expiresAt absent/unparsable -> OK (cannot prove
|
||||
expiry; we do not manufacture false positives that would wedge the shared
|
||||
queue — see ADR Risks R-1).
|
||||
|
||||
Fail-safe: any unexpected error returns (False, ...) so a logged-out / broken
|
||||
state never claims a job (BR-2 / TR-3.5). This reads only a local file — no
|
||||
network call, no token spend (BR-1 / AC-5).
|
||||
"""
|
||||
try:
|
||||
path = _credentials_path()
|
||||
if not os.path.exists(path):
|
||||
return False, f"claude not logged in: credentials missing ({path})"
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError) as e:
|
||||
return False, f"claude not logged in: credentials unreadable ({e})"
|
||||
|
||||
oauth = data.get("claudeAiOauth") if isinstance(data, dict) else None
|
||||
if not isinstance(oauth, dict) or not oauth.get("accessToken"):
|
||||
return False, "claude not logged in: no oauth token"
|
||||
|
||||
expires = oauth.get("expiresAt")
|
||||
if expires is None:
|
||||
return True, "auth ok (no expiry recorded)"
|
||||
try:
|
||||
expires_ms = int(expires)
|
||||
except (TypeError, ValueError):
|
||||
return True, "auth ok (unparsable expiry)"
|
||||
|
||||
skew_ms = int(getattr(settings, "auth_expiry_skew_seconds", 0) or 0) * 1000
|
||||
now_ms = int(time.time() * 1000)
|
||||
if expires_ms <= now_ms + skew_ms:
|
||||
return False, f"OAuth token expired at {_iso(expires_ms)}"
|
||||
return True, "auth ok"
|
||||
except Exception as e: # pragma: no cover - defensive fail-safe
|
||||
return False, f"auth check error: {e}"
|
||||
|
||||
|
||||
def _compute() -> tuple[bool, str]:
|
||||
bin_path = _claude_bin()
|
||||
if not os.path.exists(bin_path):
|
||||
return False, f"CLAUDE_BIN not found: {bin_path}"
|
||||
return _run_version(bin_path)
|
||||
ok, reason = _run_version(bin_path)
|
||||
if not ok:
|
||||
return ok, reason
|
||||
# ORCH-044 (P1): version is local info and answers even when logged out, so
|
||||
# gate on a token-free auth check too. Toggleable for emergencies.
|
||||
if getattr(settings, "preflight_check_auth", True):
|
||||
auth_ok, auth_reason = _check_auth()
|
||||
if not auth_ok:
|
||||
return False, auth_reason
|
||||
return True, reason
|
||||
|
||||
|
||||
def check(force: bool = False) -> tuple[bool, str]:
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""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"
|
||||
298
tests/test_empty_log_failure.py
Normal file
298
tests/test_empty_log_failure.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""ORCH-044 (P3): empty run log / no result-JSON at exit 0 == failure.
|
||||
|
||||
claude can exit 0 yet leave an empty (or JSON-less) run log — e.g. it died fast
|
||||
because the session was logged out, or a flag silenced stdout. Before ORCH-044
|
||||
that looked identical to success: job -> done, stage auto-advanced. Now the
|
||||
launcher validates the result; only (exit 0 AND valid result-JSON) is a success.
|
||||
|
||||
No real claude/Popen is spawned. The git/usage/notify side effects of
|
||||
_monitor_agent are stubbed; DB is a fresh per-test sqlite.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_empty_log.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
|
||||
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
|
||||
|
||||
import src.db as db
|
||||
from src.db import init_db, enqueue_job, claim_next_job, get_job
|
||||
from src import preflight
|
||||
from src.agents.launcher import AgentLauncher
|
||||
|
||||
|
||||
VALID_RESULT_LOG = (
|
||||
"some preamble text from the agent run...\n"
|
||||
'{"type":"result","subtype":"success","usage":'
|
||||
'{"input_tokens":120,"output_tokens":45},"total_cost_usd":0.12}\n'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(db.settings, "db_path", str(tmp_path / "res.db"))
|
||||
init_db()
|
||||
preflight.reset_cache()
|
||||
yield
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _validate_result — the result-JSON contract (TR-3.1)
|
||||
# ===========================================================================
|
||||
class TestValidateResult:
|
||||
def test_missing_path(self):
|
||||
ok, reason = AgentLauncher._validate_result(None)
|
||||
assert ok is False
|
||||
|
||||
def test_missing_file(self, tmp_path):
|
||||
ok, reason = AgentLauncher._validate_result(str(tmp_path / "nope.log"))
|
||||
assert ok is False
|
||||
assert "missing" in reason.lower()
|
||||
|
||||
def test_empty_file(self, tmp_path):
|
||||
p = tmp_path / "empty.log"
|
||||
p.write_text("")
|
||||
ok, reason = AgentLauncher._validate_result(str(p))
|
||||
assert ok is False
|
||||
assert "empty" in reason.lower()
|
||||
|
||||
def test_whitespace_only(self, tmp_path):
|
||||
p = tmp_path / "ws.log"
|
||||
p.write_text(" \n\t\n")
|
||||
ok, _ = AgentLauncher._validate_result(str(p))
|
||||
assert ok is False
|
||||
|
||||
def test_no_json(self, tmp_path):
|
||||
p = tmp_path / "garbage.log"
|
||||
p.write_text("this is not json at all, just noise\n")
|
||||
ok, reason = AgentLauncher._validate_result(str(p))
|
||||
assert ok is False
|
||||
assert "json" in reason.lower()
|
||||
|
||||
def test_valid_result_json(self, tmp_path):
|
||||
p = tmp_path / "good.log"
|
||||
p.write_text(VALID_RESULT_LOG)
|
||||
ok, _ = AgentLauncher._validate_result(str(p))
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _finalize_job — job state under result_ok (TC-12/13/15/16/17)
|
||||
# ===========================================================================
|
||||
class TestFinalizeJobResultOk:
|
||||
def _spy_telegram(self, monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr("src.notifications.send_telegram",
|
||||
lambda *a, **k: sent.append(a[0] if a else ""))
|
||||
return sent
|
||||
|
||||
# TC-15 / AC-13: valid result -> done (no regression).
|
||||
def test_valid_result_done(self, tmp_path, monkeypatch):
|
||||
self._spy_telegram(monkeypatch)
|
||||
log = tmp_path / "1.log"
|
||||
log.write_text(VALID_RESULT_LOG)
|
||||
jid = enqueue_job("developer", "r")
|
||||
claim_next_job()
|
||||
AgentLauncher()._finalize_job(jid, "developer", run_id=1, exit_code=0,
|
||||
output_path=str(log), result_ok=True)
|
||||
assert get_job(jid)["status"] == "done"
|
||||
|
||||
# TC-12 / AC-10: exit 0 + empty log -> NOT done; terminal failed + alert.
|
||||
def test_empty_log_exit0_terminal_failed_alerts(self, tmp_path, monkeypatch):
|
||||
sent = self._spy_telegram(monkeypatch)
|
||||
log = tmp_path / "2.log"
|
||||
log.write_text("") # 0 bytes
|
||||
# max_attempts=1 -> after the claim (attempts=1) the budget is spent ->
|
||||
# the permanent path goes straight to 'failed' and alerts.
|
||||
jid = enqueue_job("developer", "r", max_attempts=1)
|
||||
claim_next_job()
|
||||
AgentLauncher()._finalize_job(jid, "developer", run_id=2, exit_code=0,
|
||||
output_path=str(log), result_ok=False)
|
||||
job = get_job(jid)
|
||||
assert job["status"] == "failed"
|
||||
assert job["status"] != "done"
|
||||
assert "empty run log" in (job["error"] or "")
|
||||
assert sent, "a Telegram alert must be sent on terminal failure"
|
||||
|
||||
# TC-13 / AC-11: exit 0 + JSON-less log -> failure (here: requeue).
|
||||
def test_garbage_log_exit0_not_done(self, tmp_path, monkeypatch):
|
||||
self._spy_telegram(monkeypatch)
|
||||
log = tmp_path / "3.log"
|
||||
log.write_text("noise, no json here\n")
|
||||
jid = enqueue_job("developer", "r", max_attempts=2)
|
||||
claim_next_job()
|
||||
AgentLauncher()._finalize_job(jid, "developer", run_id=3, exit_code=0,
|
||||
output_path=str(log), result_ok=False)
|
||||
job = get_job(jid)
|
||||
assert job["status"] != "done"
|
||||
assert job["status"] == "queued" # retry budget remained
|
||||
assert "no result JSON" in (job["error"] or "")
|
||||
|
||||
# TC-16 / AC-14: exit 0 + empty log never leaves the job 'running'.
|
||||
def test_never_running_after_empty_result(self, tmp_path, monkeypatch):
|
||||
self._spy_telegram(monkeypatch)
|
||||
log = tmp_path / "4.log"
|
||||
log.write_text("")
|
||||
jid = enqueue_job("developer", "r", max_attempts=2)
|
||||
claim_next_job()
|
||||
assert get_job(jid)["status"] == "running" # claimed
|
||||
AgentLauncher()._finalize_job(jid, "developer", run_id=4, exit_code=0,
|
||||
output_path=str(log), result_ok=False)
|
||||
assert get_job(jid)["status"] in ("failed", "queued")
|
||||
|
||||
# TC-17 / TR-3.3: empty result defaults to permanent (no backoff, no
|
||||
# transient budget burn).
|
||||
def test_empty_result_defaults_permanent(self, tmp_path, monkeypatch):
|
||||
self._spy_telegram(monkeypatch)
|
||||
log = tmp_path / "5.log"
|
||||
log.write_text("") # no transient marker
|
||||
jid = enqueue_job("developer", "r", max_attempts=2)
|
||||
claim_next_job()
|
||||
AgentLauncher()._finalize_job(jid, "developer", run_id=5, exit_code=0,
|
||||
output_path=str(log), result_ok=False)
|
||||
job = get_job(jid)
|
||||
assert job["status"] == "queued"
|
||||
assert job["transient_attempts"] == 0 # NOT transient
|
||||
assert job["available_at"] is None # no backoff gate
|
||||
|
||||
# TC-17 / TR-3.3: a transient marker in the log routes to the transient path.
|
||||
def test_empty_result_with_transient_marker_goes_transient(self, tmp_path, monkeypatch):
|
||||
self._spy_telegram(monkeypatch)
|
||||
log = tmp_path / "6.log"
|
||||
log.write_text("overloaded_error: 429 rate limit. Retry-After: 12\n")
|
||||
jid = enqueue_job("developer", "r", max_attempts=2)
|
||||
claim_next_job()
|
||||
AgentLauncher()._finalize_job(jid, "developer", run_id=6, exit_code=0,
|
||||
output_path=str(log), result_ok=False)
|
||||
job = get_job(jid)
|
||||
assert job["status"] == "queued"
|
||||
assert job["transient_attempts"] == 1 # transient path taken
|
||||
assert job["available_at"] is not None # backoff gate set
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _monitor_agent — success gating (TC-14/15) + auth-marker reset (P1b)
|
||||
# ===========================================================================
|
||||
class _FakeProc:
|
||||
def __init__(self, exit_code):
|
||||
self._ec = exit_code
|
||||
self.pid = 4242
|
||||
|
||||
def wait(self):
|
||||
return self._ec
|
||||
|
||||
|
||||
def _seed_task_and_run(repo, branch, agent="developer", work_item_id="ORCH-001"):
|
||||
conn = db.get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO tasks (work_item_id, repo, branch, stage) VALUES (?,?,?,?)",
|
||||
(work_item_id, repo, branch, "development"),
|
||||
)
|
||||
cur = conn.execute(
|
||||
"INSERT INTO agent_runs (task_id, agent) VALUES ((SELECT id FROM tasks "
|
||||
"WHERE repo=? AND branch=?), ?)",
|
||||
(repo, branch, agent),
|
||||
)
|
||||
run_id = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return run_id
|
||||
|
||||
|
||||
class TestMonitorAgentGating:
|
||||
def _patch_monitor_env(self, monkeypatch, tmp_path):
|
||||
"""Stub the heavy side effects of _monitor_agent (git/usage/notify)."""
|
||||
monkeypatch.setattr("src.agents.launcher.notify_agent_finished",
|
||||
lambda *a, **k: None)
|
||||
monkeypatch.setattr("src.agents.launcher.get_worktree_path",
|
||||
lambda repo, branch: str(tmp_path))
|
||||
|
||||
class _R:
|
||||
returncode = 0
|
||||
stdout = "" # "no changes to commit" -> skips git add/commit/push
|
||||
stderr = ""
|
||||
|
||||
monkeypatch.setattr("src.agents.launcher.subprocess.run",
|
||||
lambda *a, **k: _R())
|
||||
|
||||
def test_success_advances_and_comments(self, tmp_path, monkeypatch):
|
||||
self._patch_monitor_env(monkeypatch, tmp_path)
|
||||
run_id = _seed_task_and_run("r", "feature/x")
|
||||
log = tmp_path / f"{run_id}.log"
|
||||
log.write_text(VALID_RESULT_LOG)
|
||||
|
||||
spy = {"post": 0, "advance": 0, "finalize": None, "alert": 0}
|
||||
monkeypatch.setattr("src.notifications.send_telegram",
|
||||
lambda *a, **k: spy.__setitem__("alert", spy["alert"] + 1))
|
||||
|
||||
lr = AgentLauncher()
|
||||
monkeypatch.setattr(lr, "_post_usage_comments",
|
||||
lambda *a, **k: spy.__setitem__("post", spy["post"] + 1))
|
||||
monkeypatch.setattr(lr, "_try_advance_stage",
|
||||
lambda *a, **k: spy.__setitem__("advance", spy["advance"] + 1))
|
||||
monkeypatch.setattr(lr, "_finalize_job",
|
||||
lambda *a, **k: spy.__setitem__("finalize", k.get("result_ok")))
|
||||
|
||||
lr._monitor_agent(_FakeProc(0), run_id, "developer", "r", "feature/x",
|
||||
output_path=str(log), log_fh=None, job_id=99)
|
||||
|
||||
assert spy["post"] == 1
|
||||
assert spy["advance"] == 1
|
||||
assert spy["finalize"] is True
|
||||
assert spy["alert"] == 0 # no empty-result alert on a valid run
|
||||
|
||||
# TC-14 / AC-12: empty result -> no advance, no success comment, alert sent.
|
||||
def test_empty_result_suppresses_advance_and_comment(self, tmp_path, monkeypatch):
|
||||
self._patch_monitor_env(monkeypatch, tmp_path)
|
||||
run_id = _seed_task_and_run("r", "feature/y")
|
||||
log = tmp_path / f"{run_id}.log"
|
||||
log.write_text("") # empty -> invalid result
|
||||
|
||||
spy = {"post": 0, "advance": 0, "finalize": None, "alert": 0}
|
||||
monkeypatch.setattr("src.notifications.send_telegram",
|
||||
lambda *a, **k: spy.__setitem__("alert", spy["alert"] + 1))
|
||||
|
||||
lr = AgentLauncher()
|
||||
monkeypatch.setattr(lr, "_post_usage_comments",
|
||||
lambda *a, **k: spy.__setitem__("post", spy["post"] + 1))
|
||||
monkeypatch.setattr(lr, "_try_advance_stage",
|
||||
lambda *a, **k: spy.__setitem__("advance", spy["advance"] + 1))
|
||||
monkeypatch.setattr(lr, "_finalize_job",
|
||||
lambda *a, **k: spy.__setitem__("finalize", k.get("result_ok")))
|
||||
|
||||
lr._monitor_agent(_FakeProc(0), run_id, "developer", "r", "feature/y",
|
||||
output_path=str(log), log_fh=None, job_id=99)
|
||||
|
||||
assert spy["post"] == 0 # no success comment
|
||||
assert spy["advance"] == 0 # stage NOT advanced
|
||||
assert spy["finalize"] is False # finalize told the result was invalid
|
||||
assert spy["alert"] == 1 # empty-result alert fired
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# _handle_auth_marker — post-factum auth detection resets preflight cache (P1b)
|
||||
# ===========================================================================
|
||||
class TestAuthMarkerHandling:
|
||||
def test_auth_marker_resets_preflight_cache(self, tmp_path, monkeypatch):
|
||||
log = tmp_path / "auth.log"
|
||||
log.write_text("Error: Not logged in. Please run /login\n")
|
||||
reset = {"n": 0}
|
||||
monkeypatch.setattr(preflight, "reset_cache",
|
||||
lambda: reset.__setitem__("n", reset["n"] + 1))
|
||||
found = AgentLauncher()._handle_auth_marker(str(log))
|
||||
assert found is True
|
||||
assert reset["n"] == 1
|
||||
|
||||
def test_no_auth_marker_no_reset(self, tmp_path, monkeypatch):
|
||||
log = tmp_path / "plain.log"
|
||||
log.write_text("Traceback: ValueError somewhere\n")
|
||||
reset = {"n": 0}
|
||||
monkeypatch.setattr(preflight, "reset_cache",
|
||||
lambda: reset.__setitem__("n", reset["n"] + 1))
|
||||
found = AgentLauncher()._handle_auth_marker(str(log))
|
||||
assert found is False
|
||||
assert reset["n"] == 0
|
||||
246
tests/test_preflight_auth.py
Normal file
246
tests/test_preflight_auth.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""ORCH-044 (P1): token-free preflight auth gate.
|
||||
|
||||
`claude --version` answers even when claude is logged OUT, so version-only
|
||||
preflight was blind to auth. These tests cover the new local credentials check:
|
||||
missing / expired / valid token, broken JSON fail-safe, no network, caching,
|
||||
HOME-correct path resolution, and the queue-worker claim gate.
|
||||
|
||||
No real claude/Popen is spawned: `_run_version` is stubbed and credentials live
|
||||
in tmp files. DB is a fresh per-test sqlite (mirrors tests/test_resilience.py).
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import socket
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_preflight_auth.db")
|
||||
os.environ["ORCH_DB_PATH"] = _test_db
|
||||
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
|
||||
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
|
||||
|
||||
import src.db as db
|
||||
from src.db import init_db, enqueue_job, get_job, count_running_jobs
|
||||
from src import preflight
|
||||
from src.queue_worker import QueueWorker
|
||||
from src.agents.launcher import AgentLauncher
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(db.settings, "db_path", str(tmp_path / "res.db"))
|
||||
init_db()
|
||||
preflight.reset_cache()
|
||||
# auth check on by default; large TTL unless a test overrides it.
|
||||
monkeypatch.setattr(preflight.settings, "preflight_check_auth", True)
|
||||
yield
|
||||
|
||||
|
||||
def _fake_bin(monkeypatch, tmp_path):
|
||||
"""A bin path that exists + a --version that always succeeds (auth-agnostic)."""
|
||||
b = tmp_path / "claude"
|
||||
b.write_text("#!/bin/sh\necho v1\n")
|
||||
monkeypatch.setattr(preflight, "_claude_bin", lambda: str(b))
|
||||
monkeypatch.setattr(preflight, "_run_version", lambda b: (True, "1.2.3"))
|
||||
|
||||
|
||||
def _write_creds(tmp_path, *, expires_ms=None, access_token="tok", oauth=True,
|
||||
raw=None):
|
||||
path = tmp_path / ".credentials.json"
|
||||
if raw is not None:
|
||||
path.write_text(raw)
|
||||
return path
|
||||
body = {}
|
||||
if oauth:
|
||||
oa = {"accessToken": access_token}
|
||||
if expires_ms is not None:
|
||||
oa["expiresAt"] = expires_ms
|
||||
body["claudeAiOauth"] = oa
|
||||
path.write_text(json.dumps(body))
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01 / AC-1: not logged in (no credentials file) -> FAIL
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_missing_credentials_fails(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(preflight, "_credentials_path",
|
||||
lambda: str(tmp_path / "nope.json"))
|
||||
ok, reason = preflight.check(force=True)
|
||||
assert ok is False
|
||||
assert "logged in" in reason.lower() or "credentials" in reason.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02 / AC-2: expired OAuth token -> FAIL
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_expired_token_fails(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
past = (int(__import__("time").time()) - 3600) * 1000 # 1h ago, epoch ms
|
||||
creds = _write_creds(tmp_path, expires_ms=past)
|
||||
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
|
||||
ok, reason = preflight.check(force=True)
|
||||
assert ok is False
|
||||
assert "expired" in reason.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-03 / AC-3: valid login -> OK (no regression)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_valid_login_ok(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
future = (int(__import__("time").time()) + 3600) * 1000 # 1h ahead
|
||||
creds = _write_creds(tmp_path, expires_ms=future)
|
||||
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
|
||||
ok, reason = preflight.check(force=True)
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_token_without_expiry_is_ok(monkeypatch, tmp_path):
|
||||
# accessToken present but no expiresAt -> cannot prove expiry -> OK (ADR §P1.5).
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
creds = _write_creds(tmp_path, expires_ms=None)
|
||||
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
|
||||
ok, _ = preflight.check(force=True)
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-04 / AC-1: broken / unreadable credentials JSON -> FAIL (no exception)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_broken_json_fails_without_raising(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
creds = _write_creds(tmp_path, raw="{ this is not valid json ")
|
||||
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
|
||||
ok, reason = preflight.check(force=True) # must not raise
|
||||
assert ok is False
|
||||
assert "logged in" in reason.lower() or "unreadable" in reason.lower()
|
||||
|
||||
|
||||
def test_no_oauth_block_fails(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
creds = _write_creds(tmp_path, oauth=False)
|
||||
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
|
||||
ok, reason = preflight.check(force=True)
|
||||
assert ok is False
|
||||
assert "oauth" in reason.lower() or "logged in" in reason.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-05 / AC-5: token-free — no network call in the auth path
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_auth_check_makes_no_network_call(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
future = (int(__import__("time").time()) + 3600) * 1000
|
||||
creds = _write_creds(tmp_path, expires_ms=future)
|
||||
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
|
||||
|
||||
def _no_net(*a, **k):
|
||||
raise AssertionError("token-free auth check must not open a socket")
|
||||
|
||||
monkeypatch.setattr(socket, "socket", _no_net)
|
||||
ok, _ = preflight.check(force=True)
|
||||
assert ok is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-06 / AC-6: auth result cached within preflight_cache_ttl
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_auth_result_cached_within_ttl(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(preflight.settings, "preflight_cache_ttl", 999)
|
||||
|
||||
calls = {"n": 0}
|
||||
real = preflight._check_auth
|
||||
|
||||
future = (int(__import__("time").time()) + 3600) * 1000
|
||||
creds = _write_creds(tmp_path, expires_ms=future)
|
||||
monkeypatch.setattr(preflight, "_credentials_path", lambda: str(creds))
|
||||
|
||||
def counting():
|
||||
calls["n"] += 1
|
||||
return real()
|
||||
|
||||
monkeypatch.setattr(preflight, "_check_auth", counting)
|
||||
preflight.reset_cache()
|
||||
preflight.check() # miss -> reads creds
|
||||
preflight.check() # cached -> no re-read
|
||||
preflight.check()
|
||||
assert calls["n"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-07 / TR-1.3: credentials path resolves from AGENT_HOME, not process env
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_credentials_path_follows_agent_home(monkeypatch, tmp_path):
|
||||
agent_home = tmp_path / "agent_home"
|
||||
agent_home.mkdir()
|
||||
monkeypatch.setattr(AgentLauncher, "AGENT_HOME", str(agent_home))
|
||||
monkeypatch.setattr(preflight.settings, "claude_credentials_path", "")
|
||||
# The orchestrator process HOME points somewhere else entirely.
|
||||
monkeypatch.setenv("HOME", str(tmp_path / "orchestrator_home"))
|
||||
|
||||
resolved = preflight._credentials_path()
|
||||
assert resolved == str(agent_home / ".claude" / ".credentials.json")
|
||||
assert str(tmp_path / "orchestrator_home") not in resolved
|
||||
|
||||
|
||||
def test_explicit_credentials_path_wins(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(preflight.settings, "claude_credentials_path",
|
||||
str(tmp_path / "explicit.json"))
|
||||
assert preflight._credentials_path() == str(tmp_path / "explicit.json")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-08 / AC-4: auth-fail blocks the queue-worker claim
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_worker_does_not_claim_when_auth_fails(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(preflight, "_credentials_path",
|
||||
lambda: str(tmp_path / "missing.json")) # not logged in
|
||||
called = {"launch": False}
|
||||
monkeypatch.setattr("src.queue_worker.launcher.launch_job",
|
||||
lambda job: called.__setitem__("launch", True))
|
||||
|
||||
jid = enqueue_job("analyst", "r")
|
||||
w = QueueWorker(max_concurrency=1, poll_interval=0.01)
|
||||
w._drain_once()
|
||||
|
||||
assert called["launch"] is False
|
||||
assert get_job(jid)["status"] == "queued"
|
||||
assert count_running_jobs() == 0
|
||||
assert w.last_preflight_ok is False
|
||||
assert "logged in" in w.last_preflight_reason.lower() \
|
||||
or "credentials" in w.last_preflight_reason.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Toggle off: preflight_check_auth=False keeps the old version-only behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_auth_toggle_off_skips_check(monkeypatch, tmp_path):
|
||||
_fake_bin(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(preflight.settings, "preflight_check_auth", False)
|
||||
monkeypatch.setattr(preflight, "_credentials_path",
|
||||
lambda: str(tmp_path / "missing.json"))
|
||||
ok, _ = preflight.check(force=True)
|
||||
assert ok is True # auth not consulted -> version-only pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_auth_failure_text: post-factum marker detection (P1b)
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.mark.parametrize("text", [
|
||||
"Error: Not logged in. Please run /login",
|
||||
"401 Unauthorized",
|
||||
"invalid api key provided",
|
||||
])
|
||||
def test_is_auth_failure_text_positive(text):
|
||||
assert preflight.is_auth_failure_text(text) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["", "429 rate limit", "Traceback ValueError"])
|
||||
def test_is_auth_failure_text_negative(text):
|
||||
assert preflight.is_auth_failure_text(text) is False
|
||||
@@ -37,6 +37,17 @@ def fresh_db(tmp_path, monkeypatch):
|
||||
# A. Preflight
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestPreflight:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_auth_gate(self, monkeypatch):
|
||||
# ORCH-044: preflight.check() also runs a token-free auth gate reading
|
||||
# <AGENT_HOME>/.claude/.credentials.json (AgentLauncher.AGENT_HOME, not the
|
||||
# process HOME). In a clean CI runner those creds are absent, so the gate
|
||||
# returns (False, ...) and version-branch assertions would fail for purely
|
||||
# environmental reasons. Stub the gate green; auth is covered by
|
||||
# tests/test_preflight_auth.py. Production default (preflight_check_auth=True)
|
||||
# is unchanged.
|
||||
monkeypatch.setattr(preflight, "_check_auth", lambda: (True, "auth ok (test stub)"))
|
||||
|
||||
def test_fail_when_bin_missing(self, monkeypatch):
|
||||
monkeypatch.setattr(preflight, "_claude_bin", lambda: "/no/such/claude")
|
||||
ok, reason = preflight.check(force=True)
|
||||
|
||||
@@ -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 "✅/⏸️ Подтверждение BRD · твоё время" line, the 💰 totals, and the finish block
|
||||
the "⏸️ Ревью БРД · твоё время" 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 Анализ" in text
|
||||
assert "\u2705 Analysis" 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 "Подтверждение BRD" in text
|
||||
assert "\u0420\u0435\u0432\u044c\u044e \u0411\u0420\u0414" in text
|
||||
assert "\u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f" in text
|
||||
# Active stage
|
||||
assert "\U0001f504 Внедрение" in text
|
||||
assert "\U0001f504 Deploy" 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 "Подтверждение BRD" in text
|
||||
assert "\u0420\u0435\u0432\u044c\u044e \u0411\u0420\u0414" 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 Анализ")][0]
|
||||
line = [l for l in text.splitlines() if l.startswith("\u2705 Analysis")][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 "Код ревью" in l][0]
|
||||
if l.startswith("\U0001f504") and "Review" 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 "Код ревью" in l][0]
|
||||
if l.startswith("\U0001f504") and "Review" in l][0]
|
||||
assert _POPYTKA not in active
|
||||
assert "\u0438\u0434\u0451\u0442" in active
|
||||
|
||||
@@ -516,112 +516,3 @@ 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
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
"""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