Compare commits
37 Commits
docs/lesso
...
feature/OR
| Author | SHA1 | Date | |
|---|---|---|---|
| 577bf8351e | |||
| 08ace892bb | |||
| 2c0745211e | |||
|
|
6fbf7a3f64 | ||
|
|
92fc118e73 | ||
| 98b47fe021 | |||
| 8fb59cd87f | |||
|
|
4488a87404 | ||
| e71a44f84f | |||
| 2f60835536 | |||
| 507c225175 | |||
| a8221f01c8 | |||
| 2a36ed80b9 | |||
| 3f1f3fc73b | |||
| 8a70398496 | |||
| 9c1c028dc1 | |||
| 81e6ec5a20 | |||
| 913c185232 | |||
| 2424f9aaad | |||
| 28d019a1e2 | |||
| d6744c3c05 | |||
|
|
7a6c7a0151 | ||
| 04e88b833f | |||
| 7203812b17 | |||
| 8b5b1f0056 | |||
| 9538103eff | |||
| 0bc2398462 | |||
| 13b7df06b1 | |||
| b5f4eb6f2f | |||
| 75c2b814d8 | |||
| be10becae2 | |||
| 4cd55063b4 | |||
| 03c3d77cac | |||
| 29e83341b5 | |||
| c7bca51d4b | |||
| 50a3c60b0e | |||
| 615a778d20 |
@@ -21,10 +21,20 @@ On stage `deploy-staging` your job is to run the staging test suite and write a
|
||||
|
||||
### Steps:
|
||||
|
||||
1. Run the staging test suite against the live staging environment:
|
||||
1. Run the staging test suite against the live staging environment.
|
||||
**CANONICAL: run INSIDE the `orchestrator-staging` container via `docker exec`**
|
||||
(ORCH-048, ADR-001) — NOT from the host:
|
||||
```bash
|
||||
python3 scripts/staging_check.py --base-url http://localhost:8501 --mode stub
|
||||
docker exec orchestrator-staging \
|
||||
python3 /repos/orchestrator/scripts/staging_check.py \
|
||||
--base-url http://localhost:8501 --mode stub
|
||||
```
|
||||
Why: the B6 registry-isolation check reads the registry from the running
|
||||
instance's own process-env (`.env.staging`). Running from the host leaves
|
||||
`ORCH_PROJECTS_JSON` unset → B6 falls back to the default (ET+ORCH) registry
|
||||
→ false FAIL → spurious rollback. The script path is `/repos/orchestrator/scripts/…`
|
||||
(bind-mount); `scripts/` is NOT copied into the image, so `/app/scripts` does
|
||||
not exist. Details: `docs/operations/STAGING_CHECK.md`.
|
||||
|
||||
2. Check the exit code:
|
||||
- Exit code **0** = all tests PASS → `staging_status: SUCCESS`
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Надёжность запуска агента: 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`.
|
||||
- **Конфигурируемые модель LLM и режим работы (`--effort`) агентов** (ORCH-41): модель/effort каждого агента вынесены из хардкода `launcher.py` в конфиг — глобально per-agent (`ORCH_AGENT_MODEL_<AGENT>` / `ORCH_AGENT_EFFORT_<AGENT>`, дефолты `ORCH_AGENT_MODEL_DEFAULT=claude-opus-4-8`, `ORCH_AGENT_EFFORT_DEFAULT=high`) и per-project (`agent_models` / `agent_efforts` в `ORCH_PROJECTS_JSON`). Резолверы `resolve_agent_model` / `resolve_agent_effort` (приоритет project > per-agent env > default > пусто), валидация effort `{low,medium,high,xhigh,max}`, опц. `ORCH_AGENT_FALLBACK_MODEL` (`--fallback-model`). Хардкод `"model":"opus"` (architect/reviewer) удалён. Тесты: `test_resolve_agent_model.py`, `test_resolve_agent_effort.py`.
|
||||
@@ -22,6 +24,7 @@
|
||||
- Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`).
|
||||
|
||||
### Fixed
|
||||
- **Staging-чек B6 читает реестр из окружения работающего staging-инстанса** (ORCH-048): блок B6 «Registry: sandbox present, prod ET/ORCH absent» в `scripts/staging_check.py` давал **ложный FAIL** (`prod-ET=YES(BAD!)`, `prod-ORCH=YES(BAD!)`) при фактически исправной изоляции — единственный чек suite, который не ходил к инстансу по HTTP, а импортировал `src.projects` локально через host-path хак `sys.path.insert(0, "/repos/orchestrator")` + `importlib.reload`, строя реестр из `ORCH_PROJECTS_JSON` **process-env запускающего процесса**. При фактическом запуске деплоером с хоста переменная не задана → дефолт `_DEFAULT_PROJECTS` (ET+ORCH) → ложный FAIL → лишний откат `deploy-staging → development`. Решение (вариант «в», ADR-001): host-path хак удалён; suite канонически запускается ВНУТРИ контейнера `orchestrator-staging` через `docker exec … python3 /repos/orchestrator/scripts/staging_check.py` (`scripts/` доступен только через bind-mount, `import src.projects` резолвится через `PYTHONPATH=/app` из кода контейнера, env — `.env.staging`) → B6 читает реестр именно работающего инстанса, без HTTP-bootstrap и «курицы-яйца». Логика вердикта вынесена в чистую `_evaluate_b6(known) -> (passed, detail)` (инвариант `passed ⟺ SANDBOX ∈ known ∧ PROD_ET ∉ known ∧ PROD_ORCH ∉ known`, формат detail сохранён) + `_known_project_ids_from_registry()` / `_run_b6()` с детерминированным FAIL при недоступности источника (не ложный PASS, не необработанное исключение). Синхронно обновлены `.openclaw/agents/deployer.md` (команда стадии через `docker exec`) и `docs/operations/STAGING_CHECK.md`. `src/projects.py`, `.env*` и прочие чеки A/B4/B5/C не тронуты; реестр `QG_CHECKS` и `check_staging_status` (ADR-0003) не менялись. ADR `docs/work-items/ORCH-048/06-adr/ADR-001-b6-registry-via-in-container-run.md`. Тесты: `tests/test_staging_check_b6.py`.
|
||||
- **Testing-гейт `check_tests_passed` читает `result:` наравне с `verdict:`/`status:`** (ORCH-047): парсер `_parse_tests_verdict` (`src/qg/checks.py`) теперь принимает три равноправных машиночитаемых поля frontmatter `13-test-report.md` — `result:` (канон промпта тестера `.openclaw/agents/tester.md`, `result: PASS|FAIL`), плюс легаси `verdict:` и `status:` (enduro-trails ET-001..ET-014); достаточно любого одного непустого. Устраняет рассинхрон контракта: тестер честно эмитил `result: PASS` без `verdict:`/`status:`, парсер попадал в ветку «нет машинного вердикта» → откат `testing → development` в петлю до исчерпания `MAX_DEVELOPER_RETRIES` (наблюдалось на ORCH-17; ORCH-016 прошёл лишь из-за избыточного дублирования полей). Семантика приоритетов сохранена и распространена на все три поля через объединённую строку: negative-токен в любом поле авторитетен (перебивает positive), наборы токенов заморожены (обратная совместимость). Сигнатура гейта, имя и реестр `QG_CHECKS` не менялись. ADR `docs/work-items/ORCH-047/06-adr/ADR-001-result-field-in-tests-gate.md`. Тесты: `tests/test_qg.py::TestCheckTestsPassed`.
|
||||
- БАГ-8: провал deploy/deploy-staging → корректный откат на `development`.
|
||||
- Изоляция тестов от живого Plane API (PR #27): autouse-фикстура сброса settings.
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
- **Webhook Receivers** (`src/webhooks/plane.py`, `gitea.py`) — приём событий, HMAC-проверка, дедупликация (`_dedup.py`). Роуты: `POST /webhook/plane`, `POST /webhook/gitea`.
|
||||
- **State Machine** (`src/stages.py`) — `STAGE_TRANSITIONS`: переходы, агент и QG каждой стадии. Хелперы: `get_next_stage`, `get_agent_for_stage`, `get_qg_for_stage`, `get_previous_stage`.
|
||||
- **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.
|
||||
|
||||
@@ -46,6 +48,13 @@ created → analysis → architecture → development → review → testing →
|
||||
- Deploy / deploy-staging FAILED → откат на `development`.
|
||||
- `get_previous_stage` использует порядок ключей `STAGE_TRANSITIONS`.
|
||||
|
||||
### Обогащение `task_desc` при заворотах (ORCH-046)
|
||||
При откате на `development` `task_desc` (попадает в `.task-dev.md` developer-агента) несёт **дословный must-fix текст**, а не только ссылку — чтобы агент видел суть претензий сразу и не повторял ту же ошибку:
|
||||
- **reviewer REQUEST_CHANGES** → дословные пункты P0/P1 из секции `## Findings` файла `12-review.md` (`extract_review_findings`);
|
||||
- **tester `check_tests_passed` FAIL** → `reason` гейта + фрагмент тела `13-test-report.md` (приоритет: `## Вывод pytest` → FAIL-строки `## Результаты` → `## Итог`; `extract_test_failures`).
|
||||
|
||||
Ссылка на полный файл-артефакт сохраняется всегда («Полный контекст»). Парсеры `src/review_parse.py` — defensive (never-raise); при отсутствующем/битом артефакте `task_desc` graceful-фоллбэк на прежнюю ссылку-строку, последовательность отката и retry-счётчик не меняются (ADR `docs/work-items/ORCH-046/06-adr/ADR-001-embed-findings-in-task-desc.md`).
|
||||
|
||||
### Plane Sync: единый status-коммент агентов (ORCH-016)
|
||||
Все агенты (analyst / architect / developer / reviewer / tester / deployer) пишут финальный коммент через **один хелпер** `usage.build_status_comment(...)` (ADR `docs/work-items/ORCH-016/06-adr/ADR-001-unified-status-comment.md`). Формат HTML, разделители `<br>`:
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -216,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 «Валидация результата») |
|
||||
|
||||
## Агенты
|
||||
|
||||
@@ -292,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`
|
||||
|
||||
| Колонка | Назначение |
|
||||
|
||||
119
docs/history/LESSONS_ORCH-048.md
Normal file
119
docs/history/LESSONS_ORCH-048.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# LESSONS — ORCH-048 (B6 staging registry isolation, вариант «в»)
|
||||
|
||||
**Дата:** 2026-06-06
|
||||
**Work item:** ORCH-048 — «staging B6 check reads registry from host worktree, not staging container»
|
||||
**Статус:** ✅ Done. Merge PR #45 (`2a36ed80`), Plane → Done, task 38 → done. Прод не тронут.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
B6-чек staging-suite давал **ложный FAIL** (`prod-ET=YES, prod-ORCH=YES`), блокируя `deploy-staging` у **всех** ORCH-задач, хотя изоляция реестра в staging работала корректно. Починили, выбрав архитектурный вариант, который **не порождает новых ловушек автономности**. По дороге словили три урока, которые стоят дороже самой фичи.
|
||||
|
||||
---
|
||||
|
||||
## 1. Root cause (для истории)
|
||||
|
||||
`scripts/staging_check.py` блок **B6** был единственным чеком suite, который не ходил по HTTP к живому инстансу, а **импортировал Python-код локально**:
|
||||
|
||||
```python
|
||||
sys.path.insert(0, "/repos/orchestrator") # host-worktree
|
||||
importlib.reload(sys.modules["src.projects"]) # подхватывает env ТЕКУЩЕГО процесса
|
||||
known = known_plane_project_ids()
|
||||
```
|
||||
|
||||
Деплоер запускал suite **с хоста**, где `ORCH_PROJECTS_JSON` не задан → `src.projects` грузил встроенный `_DEFAULT_PROJECTS` (ET+ORCH) → `known_plane_project_ids()` возвращал боевые id → **ложный FAIL**. То есть B6 проверял реестр НЕ того окружения, реестр которого реально использует staging-инстанс.
|
||||
|
||||
Изоляция при этом была исправна: внутри `orchestrator-staging` `known_plane_project_ids()` корректно отдавал только sandbox (`.env.staging`).
|
||||
|
||||
---
|
||||
|
||||
## 2. ГЛАВНЫЙ УРОК: «курица-яйцо» в staging-гейте
|
||||
|
||||
Архитектор на первом прогоне выбрал **вариант (а): новый HTTP-эндпоинт `GET /projects`**, и B6 стал ходить на него. Решение красивое (единый HTTP-стиль с остальными чеками), **но оно само себя заблокировало**:
|
||||
|
||||
- B6 проверяет **работающий** staging-инстанс (порт 8501).
|
||||
- Эндпоинт `/projects` **запечён в Docker-образ** (`src/main.py`).
|
||||
- В текущем (ещё не пересобранном) образе эндпоинта НЕТ → `GET /projects` → **404** → B6 FAIL → откат на development.
|
||||
- Чтобы чек прошёл, нужен **ручной bootstrap-деплой** образа. А деплой не происходит, потому что чек красный. **Тупик by design.**
|
||||
|
||||
Подтверждено на проде: `GET /projects` на 8501 и 8500 → 404 → `deploy-staging FAILED`.
|
||||
|
||||
**Вывод-правило:**
|
||||
> Staging-чек НЕ должен проверять то, что появляется в работающем инстансе только ПОСЛЕ деплоя проверяемой ветки. Иначе первый прогон всегда падает и требует ручного bootstrap — это прямая поломка автономности.
|
||||
|
||||
**Решение — вариант (в):** запускать suite **ВНУТРИ** staging-контейнера (`docker exec orchestrator-staging`), читать реестр из собственного process-env контейнера, убрать host-path хак. Преимущество принципиальное:
|
||||
- B6 не зависит от того, что отдаёт инстанс по HTTP.
|
||||
- `staging_check.py` берётся из bind-mount → свежий код подхватывается **без ребилда образа**.
|
||||
- **Курицы-яйца нет ни на первом прогоне, ни в будущем.**
|
||||
|
||||
Вариант (б) (`docker exec ... python3 -c "..."` + парсинг stdout) отклонён: хрупкое экранирование (см. `LESSONS_2026-06-05.md`).
|
||||
|
||||
**Как это попало в реализацию:** после FAIL под (а) — откатили ветку к analyst-артефактам (`git reset --hard <analyst-commit>`), стёрли ADR(а)+код(а), зашили в `02-trz.md §4` блок «РЕШЕНИЕ ПРИНЯТО ВЛАДЕЛЬЦЕМ: вариант (в)» с обоснованием и чек-листом, откатили задачу на `architecture` + поставили job архитектору заново. Второй прогон: arch→dev→review→tester→deploy-staging — без петель, **B6 ✓ PASS, 10/10**.
|
||||
|
||||
---
|
||||
|
||||
## 3. УРОК: орк мержит в main ТОЛЬКО логи, а не фикс-код
|
||||
|
||||
После прохождения staging орк сам:
|
||||
- закрыл задачу в `done`,
|
||||
- смержил в `main` PR с **логами** (`15-staging-log.md`, `14-deploy-log.md`),
|
||||
- но **сам фикс-код остался в feature-ветке** — `main` всё ещё содержал старый сломанный B6.
|
||||
|
||||
Это by design: фичу в main вливает **владелец**. Поймали проверкой:
|
||||
|
||||
```bash
|
||||
git fetch origin -q
|
||||
git log --oneline origin/main..origin/feature/<branch> # покажет невлитые коммиты фикса
|
||||
git show origin/main:scripts/staging_check.py | grep -c '_evaluate_b6' # 0 = фикс НЕ в main
|
||||
```
|
||||
|
||||
**Правило:**
|
||||
> Прежде чем считать задачу реально доставленной — проверить `git log origin/main..feature` и наличие ключевой функции/строки фикса в `origin/main`. `done` в Plane + смерженные логи ≠ код в main.
|
||||
|
||||
Финальный шаг: смерджить feature-PR в main (Gitea API, `Do: merge`), затем синхронизировать host-репо.
|
||||
|
||||
---
|
||||
|
||||
## 4. УРОК: rollout bind-mount-фикса = host `git pull`, без ребилда/рестарта прода
|
||||
|
||||
ORCH-048 менял только **bind-mounted / non-runtime** артефакты:
|
||||
|
||||
| Файл | Как доходит до прода |
|
||||
|------|----------------------|
|
||||
| `scripts/staging_check.py` | bind-mount (`/home/slin/repos` → `/repos`); **не** в образе (`scripts/` нет в `/app`) → host `git pull` → live сразу |
|
||||
| `.openclaw/agents/deployer.md` | bind-mounted промпт, читается при запуске агента → live на следующем запуске |
|
||||
| `tests/`, `docs/` | не деплоятся |
|
||||
|
||||
`src/` и `Dockerfile` НЕ менялись → **рестарт/ребилд прод-контейнера 8500 не нужен и не делался** (zero group-risk для ET).
|
||||
|
||||
**Грабли host-репо:** `git pull` в `/home/slin/repos/orchestrator` сначала упёрся в `sudo: a password is required` — ложная тревога. Репо принадлежит `slin`, sudo не нужен; прямой `git pull --ff-only origin main` прошёл. **Сначала проверь `ls -ld` / `stat -c %U` репо — не лезь в sudo вслепую.**
|
||||
|
||||
**Верификация rollout в живом bind-mount (обязательна):**
|
||||
```bash
|
||||
grep -c '_evaluate_b6' scripts/staging_check.py # >=1
|
||||
grep -c 'sys.path.insert(0, "/repos/orchestrator")' scripts/staging_check.py # 0
|
||||
grep -c 'docker exec orchestrator-staging' .openclaw/agents/deployer.md # >=1
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:8500/health # 200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Технические заметки (gotchas)
|
||||
|
||||
- **В контейнере orchestrator НЕТ `curl`** — для Gitea/Plane API использовать `urllib` через python (script-file → base64 → `docker cp` → `docker exec`).
|
||||
- **Plane state-id зависят от проекта.** Approved для проекта orchestrator = `63f2c8fe-dcda-4ace-952f-dd88bd0118ff` (НЕ дефолтный `a519a341...` из кода — тот для sandbox/ET). Брать реальные state-id через `GET .../states/`.
|
||||
- **BRD-апрув = перевод Plane-issue в статус Approved** → webhook ловит смену статуса → путь `agent=None` → `approved-via-status` → гейт пропускает, БЕЗ повторного запуска `check_analysis_approved`.
|
||||
- **Dockerfile НЕ копирует `scripts/`** в образ — `staging_check.py` доступен в контейнере только через mount. Путь запуска внутри контейнера учитывать (не `/app/scripts`).
|
||||
- **Перезапуск стадии вручную:** `update_task_stage(task_id, "<stage>")` + `enqueue_job(agent, repo, task_content, task_id)`. Guard перед этим: `agent_running IS NULL` И нет jobs со `status IN ('queued','running')` для task_id.
|
||||
|
||||
---
|
||||
|
||||
## 6. Итог по гейтам/ядру после серии ORCH-45/46/47/48
|
||||
|
||||
- ✅ `check_ci_green` — поллинг (ORCH-45)
|
||||
- ✅ `check_tests_passed` — читает `result:` (ORCH-47)
|
||||
- ✅ `stage_engine` — передаёт деву **текст** findings, не только ссылку (ORCH-46)
|
||||
- ✅ B6 staging — читает реестр ВНУТРИ staging-контейнера, больше не ложный FAIL (ORCH-48) → **deploy-staging разблокирован для всех ORCH-задач**
|
||||
|
||||
Конвейер стал по-настоящему автономным: задача проходит analyst→deploy без ручного пинания стадий.
|
||||
@@ -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).
|
||||
|
||||
@@ -36,34 +36,53 @@ Exit code: **0** = все PASS, **non-zero** = есть FAIL.
|
||||
|
||||
## Способы запуска
|
||||
|
||||
### 1. Внутри контейнера (рекомендуемый)
|
||||
|
||||
```bash
|
||||
docker exec orchestrator-staging \
|
||||
python3 /repos/orchestrator/scripts/staging_check.py --mode stub
|
||||
```
|
||||
|
||||
### 2. С хоста (если есть токены в env)
|
||||
|
||||
```bash
|
||||
export ORCH_STAGING=true
|
||||
export ORCH_PLANE_API_TOKEN=...
|
||||
# ... остальные переменные ...
|
||||
|
||||
python3 scripts/staging_check.py \
|
||||
--base-url http://localhost:8501 \
|
||||
--mode stub
|
||||
```
|
||||
|
||||
### 3. Из docker exec с передачей URL
|
||||
### 1. Внутри контейнера (КАНОНИЧЕСКИЙ — обязателен для деплоера)
|
||||
|
||||
```bash
|
||||
docker exec orchestrator-staging \
|
||||
python3 /repos/orchestrator/scripts/staging_check.py \
|
||||
--base-url http://localhost:8501 \
|
||||
--mode stub
|
||||
--base-url http://localhost:8501 --mode stub
|
||||
```
|
||||
|
||||
Это единственный канонический способ для стадии `deploy-staging` (ORCH-048, ADR-001).
|
||||
Внутри контейнера env уже staging (`.env.staging`), а чек **B6** строит реестр проектов из
|
||||
собственного process-env инстанса (см. ниже). Путь к скрипту — `/repos/orchestrator/scripts/…`
|
||||
(bind-mount); `scripts/` **не** копируется в образ, поэтому `/app/scripts` не существует.
|
||||
|
||||
### 2. С хоста — НЕ рекомендуется
|
||||
|
||||
```bash
|
||||
# ⚠️ Воспроизводит баг ORCH-048: на хосте ORCH_PROJECTS_JSON не задан →
|
||||
# B6 строит реестр из дефолта (ET+ORCH) → ложный FAIL.
|
||||
# Допустимо ТОЛЬКО если env хоста полностью повторяет staging (включая ORCH_PROJECTS_JSON).
|
||||
export ORCH_STAGING=true
|
||||
export ORCH_PROJECTS_JSON=... # обязателен, иначе B6 даст ложный FAIL
|
||||
export ORCH_PLANE_API_TOKEN=...
|
||||
# ... остальные переменные ...
|
||||
|
||||
python3 scripts/staging_check.py --base-url http://localhost:8501 --mode stub
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Механика чека B6 (ORCH-048, ADR-001)
|
||||
|
||||
B6 «Registry: sandbox present, prod ET/ORCH absent» подтверждает изоляцию: в реестре
|
||||
работающего staging-инстанса есть только sandbox-проект и НЕТ боевых (ET/ORCH).
|
||||
|
||||
- B6 импортирует `known_plane_project_ids()` из `src.projects` **кода контейнера**
|
||||
(`/app/src` через `PYTHONPATH=/app`), env которого — `.env.staging`. Реестр отражает
|
||||
именно работающий staging-инстанс.
|
||||
- Прежний host-path хак (`sys.path.insert(0, "/repos/orchestrator")` + `importlib.reload`)
|
||||
удалён: он подхватывал env процесса-запускателя и при запуске с хоста давал ложный FAIL.
|
||||
- Логика вердикта вынесена в чистую функцию `_evaluate_b6(known) -> (passed, detail)`:
|
||||
`passed ⟺ SANDBOX ∈ known ∧ PROD_ET ∉ known ∧ PROD_ORCH ∉ known`. Покрыта юнит-тестами
|
||||
(`tests/test_staging_check_b6.py`) на оба исхода без поднятия инстанса/docker.
|
||||
- При недоступности источника реестра B6 даёт детерминированный FAIL (не ложный PASS,
|
||||
не необработанное исключение).
|
||||
|
||||
**Поэтому B6 достоверен только при каноническом запуске (способ 1).**
|
||||
|
||||
---
|
||||
|
||||
## Режимы (`--mode`)
|
||||
|
||||
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**.
|
||||
90
docs/work-items/ORCH-044/14-deploy-log.md
Normal file
90
docs/work-items/ORCH-044/14-deploy-log.md
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
timestamp: 2026-06-06T08:44:04Z
|
||||
work_item: ORCH-044
|
||||
branch: feature/ORCH-044-preflight-auth-effort
|
||||
commit: 08ace892bbf1809a65c1dc504459d052bfd71f79
|
||||
target_service: orchestrator
|
||||
target_port: 8500
|
||||
deploy_mode: artifact-only
|
||||
staging_gate: SUCCESS
|
||||
prod_container_restarted: false
|
||||
rebuild_required: true
|
||||
---
|
||||
|
||||
# Deploy Log — ORCH-044
|
||||
|
||||
## Verdict
|
||||
|
||||
**`deploy_status: SUCCESS`** — артефактный (artifact-only) деплой-вердикт.
|
||||
|
||||
Реальный `git pull` + `docker compose ... --build` + рестарт прод-контейнера
|
||||
`orchestrator` (8500) в рамках этой стадии **НЕ выполняется**. Он делегирован
|
||||
хуку `scripts/orchestrator-deploy-hook.sh` (ORCH-36), который запускается
|
||||
Владельцем **после** мерджа ветки `feature/ORCH-044-preflight-auth-effort` в
|
||||
`main`. Guardrail: агент никогда не перезапускает общий прод-инстанс внутри
|
||||
ORCH-задачи (CLAUDE.md / INFRA.md §Self-hosting).
|
||||
|
||||
## Pre-conditions (все ✓)
|
||||
|
||||
| Артефакт | Поле | Значение |
|
||||
|----------|------|----------|
|
||||
| `12-review.md` | `verdict` | `APPROVED` |
|
||||
| `13-test-report.md` | `result` | `PASS` |
|
||||
| `15-staging-log.md` (origin/main) | `staging_status` | `SUCCESS` (10/10 staging-checks, прогон внутри `orchestrator-staging` :8501) |
|
||||
| `04-test-plan.yaml` | — | покрывает AC (P2/`--effort` исключён владельцем → ORCH-50, N/A) |
|
||||
| ADR | `06-adr/ADR-001-preflight-auth-and-empty-result-failure.md` | заведён |
|
||||
| `CHANGELOG.md` | — | обновлён |
|
||||
|
||||
Стадия `deploy` достижима только потому, что условный staging-гейт
|
||||
(`check_staging_status`, реальный для self-hosting repo=orchestrator) — зелёный.
|
||||
|
||||
## Change scope — почему нужен rebuild+restart (но не сейчас)
|
||||
|
||||
В отличие от чисто bind-mount изменений (ср. ORCH-048), ORCH-044 меняет
|
||||
**рантайм-код `src/`**, который копируется в образ (`/app/src`) и исполняется
|
||||
прод-процессом:
|
||||
|
||||
| Файл | Тип | Как доезжает до прода |
|
||||
|------|-----|------------------------|
|
||||
| `src/preflight.py` | runtime (в образе) | требует **rebuild + restart** контейнера |
|
||||
| `src/agents/launcher.py` | runtime (в образе) | требует **rebuild + restart** контейнера |
|
||||
| `src/config.py` | runtime (в образе) | требует **rebuild + restart** контейнера |
|
||||
| `docs/**`, `CHANGELOG.md` | docs | мерж в `main` |
|
||||
| `tests/**` | тесты, не деплоятся | n/a |
|
||||
|
||||
`rebuild_required: true`. Чтобы новый token-free auth-гейт preflight и
|
||||
«пустой лог ⇒ провал» вступили в силу на проде, прод-инстанс `orchestrator`
|
||||
(8500) должен быть пересобран и перезапущен. **Это делает Владелец через
|
||||
деплой-хук после мерджа**, не данный агент.
|
||||
|
||||
## Self-hosting policy
|
||||
|
||||
> ORCH-044 правит слой запуска агента (preflight/launcher/config) того самого
|
||||
> инструмента, который СЕЙЧАС обслуживает все прод-проекты (orchestrator +
|
||||
> enduro-trails) из одного инстанса `orchestrator:8500` с общей БД и общей
|
||||
> очередью.
|
||||
|
||||
Поэтому в рамках этой стадии:
|
||||
- **Прод-контейнер `orchestrator` (8500) НЕ трогался** — ни рестарта, ни
|
||||
пересборки (групповой риск для всех проектов).
|
||||
- **Деплой-хук** `scripts/orchestrator-deploy-hook.sh` (реальный docker/SSH)
|
||||
**не запускался** этим агентом (не было явной инструкции Owner; зарезервирован
|
||||
за ним, ORCH-36). У хука есть health-цикл (10×6с) + авто-rollback —
|
||||
страховка на момент боевого rebuild+restart.
|
||||
- **Страховка пройдена:** staging (8501, изолированная БД/реестр) — зелёный
|
||||
перед прод-деплоем (ORCH-35).
|
||||
|
||||
## Deploy action
|
||||
|
||||
- **Prod rebuild/restart:** требуется (`src/` изменён), **не выполнен** этим
|
||||
агентом (guardrail self-hosting). Выполняется Владельцем через деплой-хук
|
||||
после мерджа в `main`.
|
||||
- **Эффективный rollout:** мерж ветки в `main` → Owner запускает
|
||||
`scripts/orchestrator-deploy-hook.sh` (прод-режим: `TARGET_SERVICE=orchestrator
|
||||
TARGET_PORT=8500 COMPOSE_PROFILE=""`) с health-check + авто-rollback.
|
||||
|
||||
## Verdict
|
||||
|
||||
`deploy_status: SUCCESS` — все гейты зелёные, артефакт-вердикт зафиксирован,
|
||||
боевой rebuild+restart делегирован Owner-хуку. Прод-инстанс не затронут.
|
||||
7
docs/work-items/ORCH-046/00-business-request.md
Normal file
7
docs/work-items/ORCH-046/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: stage_engine: pass reviewer/tester findings text to developer (not just link)
|
||||
|
||||
Work Item ID: ORCH-046
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
86
docs/work-items/ORCH-046/01-brd.md
Normal file
86
docs/work-items/ORCH-046/01-brd.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# BRD — ORCH-046: pass reviewer/tester findings text to developer (not just link)
|
||||
|
||||
Work Item ID: ORCH-046
|
||||
Stage: analysis
|
||||
Author: analyst
|
||||
Date: 2026-06-06
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
Оркестратор при заворотах задачи деву (откат на `development`) формирует
|
||||
описание задачи (`task_desc`), которое попадает в `.task-dev.md` запускаемого
|
||||
агента-разработчика. Сейчас в двух ветках отката этот текст содержит **только
|
||||
ссылку на файл-артефакт**, без сути замечаний:
|
||||
|
||||
- **Reviewer → REQUEST_CHANGES** (`src/stage_engine.py`, ветка
|
||||
`_handle_qg_failure_rollbacks`, ~стр. 419): `task_desc` =
|
||||
`"…Fix findings in docs/work-items/<id>/12-review.md"`.
|
||||
- **Tester → FAIL** (`check_tests_passed`, ~стр. 455): `task_desc` =
|
||||
`"…Fix failures described in docs/work-items/<id>/13-test-report.md"`.
|
||||
|
||||
В результате developer-агент получает инструкцию «иди читай файл». Ключевые
|
||||
претензии (P0/P1 у ревьюера, причина падения у тестера) часто проскакивают —
|
||||
агент не открывает файл целиком или теряет фокус, повторяет ту же ошибку, и
|
||||
задача снова заворачивается. Это «испорченный телефон»: расход циклов retry
|
||||
(`MAX_DEVELOPER_RETRIES = 3`), деньги на токены, простой конвейера.
|
||||
|
||||
## 2. Бизнес-цель
|
||||
|
||||
Убрать «испорченный телефон» между reviewer/tester и developer при заворотах:
|
||||
встраивать **дословный текст ключевых замечаний** прямо в `task_desc`, чтобы
|
||||
developer-агент видел суть претензий сразу, а не только ссылку.
|
||||
|
||||
Это снижает число повторных заворотов и расход retry-бюджета на одну задачу.
|
||||
|
||||
## 3. Объём (вариант A — выбран Славой 06.06)
|
||||
|
||||
Минимальное, низкорисковое изменение **ядра** (`stage_engine`), которое:
|
||||
|
||||
1. Извлекает из `12-review.md` блок findings и выносит **must-fix (P0/P1)
|
||||
дословно** в `task_desc` при reviewer REQUEST_CHANGES.
|
||||
2. Извлекает из `13-test-report.md` причину FAIL (reason из гейта + релевантный
|
||||
фрагмент тела отчёта) в `task_desc` при tester FAIL.
|
||||
3. Во всех случаях **сохраняет ссылку на полный файл** как дополнительный
|
||||
контекст («полный контекст — см. файл»).
|
||||
4. Извлечение выполняется новым отдельным хелпером-парсером
|
||||
(`src/review_parse.py`), который **никогда не бросает исключение**: при
|
||||
отсутствующем/битом файле возвращает пустой результат, и вызывающий код
|
||||
делает graceful fallback на прежнюю ссылку-строку.
|
||||
|
||||
## 4. Что НЕ входит в объём (out of scope)
|
||||
|
||||
- НЕ трогать гейты `check_*` (в т. ч. ORCH-45 `check_ci_green`,
|
||||
ORCH-47 `_parse_tests_verdict`) — они в проде, поведение неизменно.
|
||||
- НЕ трогать реестр `QG_CHECKS`.
|
||||
- НЕ менять сигнатуры публичных функций (`advance_stage`, `_run_qg`,
|
||||
`check_*`).
|
||||
- НЕ менять webhook-пути.
|
||||
- НЕ менять retry-счётчик (`_developer_retry_count`, `MAX_DEVELOPER_RETRIES`)
|
||||
и rollback-логику (последовательность `update_task_stage` →
|
||||
`notify_stage_change` → `plane_notify_stage` → enqueue) — поведение
|
||||
идентично.
|
||||
- НЕ менять формат Plane-комментариев (`build_status_comment`).
|
||||
|
||||
## 5. Заинтересованные стороны
|
||||
|
||||
- **Owner (Слава)** — заказчик, выбрал вариант A.
|
||||
- **Developer-агенты** — потребители `task_desc`: получают суть замечаний.
|
||||
- **Конвейер всех проектов** (self-hosting) — выигрывает за счёт меньшего
|
||||
числа заворотов.
|
||||
|
||||
## 6. Ограничения и риски (self-hosting)
|
||||
|
||||
- Правка ядра `stage_engine` — компонент крутится в продакшене и обслуживает
|
||||
все проекты из общего инстанса/БД/очереди. Любая регрессия в формировании
|
||||
`task_desc` или (тем более) исключение в `advance_stage` останавливает
|
||||
конвейер всех проектов → **парсер обязан быть полностью graceful**.
|
||||
- Обязателен прогон `deploy-staging` (8501) перед прод-деплоем.
|
||||
- Это правка ядра → требуется ADR (per-work-item).
|
||||
|
||||
## 7. Критерий успеха (бизнес)
|
||||
|
||||
- При заворотах в `.task-dev.md` есть дословный текст ключевых замечаний
|
||||
(P0/P1 ревьюера; reason+фрагмент тестера) плюс ссылка на полный файл.
|
||||
- Парсер устойчив к битым/отсутствующим артефактам (graceful fallback на
|
||||
старую ссылку-строку).
|
||||
- Существующие тесты зелёные; поведение retry/rollback не изменилось.
|
||||
209
docs/work-items/ORCH-046/02-trz.md
Normal file
209
docs/work-items/ORCH-046/02-trz.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# ТЗ — ORCH-046: встраивание текста findings reviewer/tester в task_desc
|
||||
|
||||
Work Item ID: ORCH-046
|
||||
Stage: analysis
|
||||
Author: analyst
|
||||
Date: 2026-06-06
|
||||
|
||||
> Вариант A (минимальный, низкий риск). Это правка ЯДРА — обязателен ADR
|
||||
> (per-work-item, `docs/work-items/ORCH-046/06-adr/`).
|
||||
|
||||
## 1. Задействованные модули `src/`
|
||||
|
||||
| Модуль | Изменение |
|
||||
|--------|-----------|
|
||||
| `src/review_parse.py` | **НОВЫЙ** хелпер-парсер: `extract_review_findings(path) -> str`, `extract_test_failures(path) -> str`. |
|
||||
| `src/stage_engine.py` | Две ветки в `_handle_qg_failure_rollbacks`: reviewer REQUEST_CHANGES (~стр. 419) и tester `check_tests_passed` FAIL (~стр. 455) — встраивают извлечённый текст в `task_desc`. |
|
||||
|
||||
Источники-образцы (не менять, использовать как референс паттерна «never raise» и
|
||||
формата артефактов):
|
||||
- `src/qg/checks.py::_parse_tests_verdict` — образец «never raise», split по `---`, `yaml.safe_load`.
|
||||
- `src/frontmatter.py::read_frontmatter_value` — образец defensive-парсера.
|
||||
- `.openclaw/agents/reviewer.md` — канонический формат `12-review.md`.
|
||||
- `.openclaw/agents/tester.md` — канонический формат `13-test-report.md`.
|
||||
|
||||
## 2. Новый модуль `src/review_parse.py`
|
||||
|
||||
### 2.1. `extract_review_findings(path: str) -> str`
|
||||
|
||||
Назначение: вернуть **дословный** текст must-fix findings (P0 + P1) из
|
||||
`12-review.md` для встраивания в `task_desc`.
|
||||
|
||||
Формат входного файла (канон reviewer.md, секция `## Findings`):
|
||||
|
||||
```markdown
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- [ ] <описание>
|
||||
|
||||
### P1 — Must fix
|
||||
- [ ] <описание>
|
||||
|
||||
### P2 — Should fix
|
||||
- [ ] <описание>
|
||||
```
|
||||
|
||||
Требования к реализации:
|
||||
|
||||
1. **Никогда не бросает исключение.** Любая ошибка (нет файла, IOError, кривой
|
||||
markdown, нет секции `## Findings`) → возврат `""` (пустая строка).
|
||||
2. Парсит **только** подсекции P0 и P1 (must-fix). P2/P3 игнорируются.
|
||||
3. Заголовки подсекций распознаются устойчиво к регистру и к тире/дефису:
|
||||
соответствие по наличию токена `P0` / `P1` в строке-заголовке уровня `###`.
|
||||
4. Из распознанных подсекций берётся текст до следующего заголовка `###`/`##`
|
||||
(т. е. тело подсекции дословно: пункты списка `- [ ] …` / `- …`).
|
||||
5. Пустые подсекции (нет содержательных пунктов, только `(если есть)`-плейсхолдер
|
||||
или ничего) — пропускаются. Если ни одного содержательного P0/P1 пункта нет
|
||||
→ возврат `""`.
|
||||
6. Результат — компактный многострочный текст, пригодный для вставки в
|
||||
`task_desc` (например, заголовок подсекции + её пункты). Длина результата
|
||||
ограничивается разумным лимитом (`MAX_FINDINGS_CHARS`, напр. 2000) с
|
||||
усечением и маркером `…(truncated)`; полный контекст всё равно остаётся в
|
||||
файле.
|
||||
7. Frontmatter (верхний `--- … ---`) при необходимости отбрасывается, чтобы не
|
||||
попасть в тело; парсинг секции делается по телу markdown.
|
||||
|
||||
Сигнатура и контракт (стабильны):
|
||||
```python
|
||||
def extract_review_findings(path: str) -> str:
|
||||
"""Дословный текст P0/P1 findings из 12-review.md. Never raises; '' при ошибке/пусто."""
|
||||
```
|
||||
|
||||
### 2.2. `extract_test_failures(path: str) -> str`
|
||||
|
||||
Назначение: вернуть текст причины падения тестов из `13-test-report.md` для
|
||||
встраивания в `task_desc`.
|
||||
|
||||
Формат входного файла (канон tester.md): frontmatter `result: PASS|FAIL`, далее
|
||||
тело с секциями `## Результаты` (таблица TC), `## Вывод pytest`, `## Итог`.
|
||||
|
||||
Требования к реализации:
|
||||
|
||||
1. **Никогда не бросает исключение.** Любая ошибка → возврат `""`.
|
||||
2. Извлекает релевантный фрагмент тела, помогающий понять причину FAIL.
|
||||
Приоритет источников (берём первый непустой):
|
||||
- секция `## Вывод pytest` (вывод прогона — где видно упавшие тесты), и/или
|
||||
- строки таблицы `## Результаты`, содержащие `FAIL`, и/или
|
||||
- секция `## Итог`.
|
||||
3. Результат усекается до `MAX_FAILURES_CHARS` (напр. 2000) с маркером
|
||||
`…(truncated)`.
|
||||
4. Если ничего извлечь не удалось → возврат `""` (вызывающий код делает
|
||||
fallback на ссылку).
|
||||
|
||||
> Примечание: «reason» из самого гейта (`check_tests_passed` → второй элемент
|
||||
> кортежа) у вызывающего кода уже есть (`reason`) — он добавляется в `task_desc`
|
||||
> вызывающим кодом (как и сейчас в комментарии тестера). `extract_test_failures`
|
||||
> добавляет **фрагмент тела отчёта** поверх этого reason.
|
||||
|
||||
Сигнатура и контракт (стабильны):
|
||||
```python
|
||||
def extract_test_failures(path: str) -> str:
|
||||
"""Релевантный фрагмент тела 13-test-report.md (причина FAIL). Never raises; '' при ошибке/пусто."""
|
||||
```
|
||||
|
||||
### 2.3. Общие требования модуля
|
||||
|
||||
- Модуль логирует диагностические сообщения на уровне `logger.debug`
|
||||
(`logging.getLogger("orchestrator.review_parse")`), как `frontmatter.py`.
|
||||
- Никаких сетевых вызовов, только чтение файла с диска.
|
||||
- Константы лимитов вынесены модульными (`MAX_FINDINGS_CHARS`,
|
||||
`MAX_FAILURES_CHARS`).
|
||||
|
||||
## 3. Изменения `src/stage_engine.py`
|
||||
|
||||
### 3.1. Ветка reviewer REQUEST_CHANGES (внутри `_handle_qg_failure_rollbacks`)
|
||||
|
||||
Текущее (~стр. 418–424):
|
||||
```python
|
||||
task_desc = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: development\nNote: REQUEST_CHANGES from reviewer "
|
||||
f"(attempt {retry_count+1}/3). Fix findings in "
|
||||
f"docs/work-items/{work_item_id}/12-review.md"
|
||||
)
|
||||
```
|
||||
|
||||
Целевое поведение:
|
||||
- Сформировать путь к `12-review.md` через `get_worktree_path(repo, branch)` +
|
||||
`docs/work-items/{work_item_id}/12-review.md` (как в `_check_review_approved_by_branch`).
|
||||
- Вызвать `extract_review_findings(path)`.
|
||||
- Если результат непустой — встроить findings **дословно** в `task_desc`
|
||||
(под подзаголовком, напр. `Findings (P0/P1):\n<text>`), а ссылку на файл
|
||||
оставить как «полный контекст» (`Полный контекст: docs/work-items/<id>/12-review.md`).
|
||||
- Если результат пустой (graceful fallback) — `task_desc` остаётся **как
|
||||
сейчас** (ссылка-строка). Никаких исключений.
|
||||
- Префиксная часть (`Work item / Repo / Branch / Stage / Note: REQUEST_CHANGES …
|
||||
(attempt N/3)`) сохраняется без изменений.
|
||||
|
||||
### 3.2. Ветка tester FAIL (`check_tests_passed`, внутри `_handle_qg_failure_rollbacks`)
|
||||
|
||||
Текущее (~стр. 454–459):
|
||||
```python
|
||||
task_desc = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: development\nNote: Tests FAILED. "
|
||||
f"Fix failures described in docs/work-items/{work_item_id}/13-test-report.md"
|
||||
)
|
||||
```
|
||||
|
||||
Целевое поведение:
|
||||
- Сформировать путь к `13-test-report.md` аналогично.
|
||||
- Вызвать `extract_test_failures(path)`.
|
||||
- В `task_desc` всегда включить `reason` (он уже доступен в этой ветке —
|
||||
передаётся в `_handle_qg_failure_rollbacks`).
|
||||
- Если фрагмент тела непустой — встроить его дословно
|
||||
(`Причина: {reason}\nДетали:\n<fragment>`), плюс ссылку на файл как полный
|
||||
контекст.
|
||||
- Если фрагмент пустой — `task_desc` содержит `reason` + ссылку (graceful
|
||||
fallback, не хуже текущего поведения). Никаких исключений.
|
||||
- Префиксная часть и существующий Plane-комментарий тестера
|
||||
(`❌ Тесты не прошли: {reason}…`) НЕ меняются.
|
||||
|
||||
### 3.3. Инварианты (НЕ менять поведение)
|
||||
|
||||
- Последовательность rollback в обеих ветках: `update_task_stage(task_id,
|
||||
"development")` → `notify_stage_change` → `plane_notify_stage` →
|
||||
(`set_issue_in_progress` для тестера) → проверка `_developer_retry_count` <
|
||||
`MAX_DEVELOPER_RETRIES` → `enqueue_job("developer", …)` либо
|
||||
`send_telegram` alert. Порядок и условия идентичны.
|
||||
- `result.rolled_back_to`, `result.enqueued_agent`, `result.enqueued_job_id`,
|
||||
`result.alerted` выставляются как сейчас.
|
||||
- Меняется **только содержимое строки `task_desc`**, передаваемой в
|
||||
`enqueue_job`.
|
||||
- Импорт нового модуля — `from .review_parse import extract_review_findings,
|
||||
extract_test_failures` в шапке `stage_engine.py`.
|
||||
|
||||
## 4. Изменения API
|
||||
|
||||
Нет. Публичные HTTP-эндпоинты (`/health`, `/status`, `/queue`,
|
||||
`/webhook/plane`, `/webhook/gitea`) не затрагиваются.
|
||||
|
||||
## 5. Изменения схемы БД
|
||||
|
||||
Нет. Таблицы `tasks`, `agent_runs`, `jobs`, `events` не меняются.
|
||||
`enqueue_job` вызывается с прежней сигнатурой.
|
||||
|
||||
## 6. Требования к новым QG checks
|
||||
|
||||
Нет. Реестр `QG_CHECKS` и все `check_*` не трогаются (явно out of scope).
|
||||
|
||||
## 7. Артефакты pipeline (создать/обновить в этом PR)
|
||||
|
||||
- `src/review_parse.py` — новый модуль.
|
||||
- `tests/test_review_parse.py` — юнит-тесты парсера (см. 04-test-plan.yaml).
|
||||
- Возможные дополнения в `tests/test_stage_engine.py` — проверка встраивания
|
||||
текста в `task_desc` (rollback-ветки).
|
||||
- `docs/work-items/ORCH-046/06-adr/ADR-001-*.md` — ADR (правка ядра).
|
||||
- `docs/architecture/README.md` / `internals.md` — описание нового хелпера и
|
||||
поведения заворотов (если reviewer сочтёт необходимым; компонент описать в
|
||||
разделе Stage Engine / Откаты).
|
||||
- `CHANGELOG.md` — запись о ORCH-046.
|
||||
|
||||
## 8. Контроль качества / проверка
|
||||
|
||||
```bash
|
||||
python -m pytest tests/ -q # в контейнере; все тесты зелёные
|
||||
```
|
||||
|
||||
Обязательно: стадия `deploy-staging` (8501) перед прод-деплоем (self-hosting).
|
||||
99
docs/work-items/ORCH-046/03-acceptance-criteria.md
Normal file
99
docs/work-items/ORCH-046/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Критерии приёмки — ORCH-046
|
||||
|
||||
Work Item ID: ORCH-046
|
||||
Stage: analysis
|
||||
Author: analyst
|
||||
Date: 2026-06-06
|
||||
|
||||
Каждый критерий имеет чёткое условие PASS/FAIL. Reviewer/Tester проверяют по
|
||||
этому списку.
|
||||
|
||||
## AC-1 — Дословные P0/P1 findings ревьюера в task_desc
|
||||
|
||||
**Условие:** при reviewer REQUEST_CHANGES (откат `review`/`testing` →
|
||||
`development`) строка `task_desc`, переданная в `enqueue_job("developer", …)`,
|
||||
содержит ДОСЛОВНЫЙ текст findings уровня P0/P1 из `12-review.md` (не только
|
||||
ссылку).
|
||||
|
||||
- **PASS:** в `task_desc` присутствуют дословные строки P0/P1 пунктов из секции
|
||||
`## Findings` файла `12-review.md`.
|
||||
- **FAIL:** `task_desc` содержит только ссылку на файл, без текста findings (при
|
||||
наличии валидного файла с P0/P1).
|
||||
|
||||
## AC-2 — Причина падения тестера в task_desc
|
||||
|
||||
**Условие:** при tester FAIL (`check_tests_passed`, откат `testing` →
|
||||
`development`) строка `task_desc` содержит причину падения: `reason` из гейта +
|
||||
релевантный фрагмент тела `13-test-report.md`.
|
||||
|
||||
- **PASS:** `task_desc` содержит `reason` И непустой фрагмент тела отчёта
|
||||
(вывод pytest / FAIL-строки / Итог), когда отчёт валиден.
|
||||
- **FAIL:** `task_desc` содержит только ссылку на файл без причины/фрагмента
|
||||
(при наличии валидного отчёта).
|
||||
|
||||
## AC-3 — Ссылка на полный файл сохранена
|
||||
|
||||
**Условие:** в обеих ветках (reviewer, tester) `task_desc` по-прежнему содержит
|
||||
ссылку на полный файл-артефакт (`docs/work-items/<id>/12-review.md` /
|
||||
`13-test-report.md`) как дополнительный контекст.
|
||||
|
||||
- **PASS:** путь к файлу присутствует в `task_desc` в обоих сценариях.
|
||||
- **FAIL:** ссылка на файл удалена/отсутствует.
|
||||
|
||||
## AC-4 — Парсер устойчив к отсутствию/битому файлу (graceful)
|
||||
|
||||
**Условие:** `extract_review_findings(path)` и `extract_test_failures(path)`
|
||||
НИКОГДА не бросают исключение; при отсутствующем/нечитаемом/битом файле
|
||||
возвращают `""`, а вызывающий код в `stage_engine` делает fallback на прежнюю
|
||||
ссылку-строку.
|
||||
|
||||
- **PASS:** на несуществующем пути, пустом файле, файле без секций, битом
|
||||
markdown/YAML — функции возвращают `""` без исключения; `advance_stage`
|
||||
отрабатывает откат как раньше (ссылка-строка в `task_desc`).
|
||||
- **FAIL:** любое исключение наружу из парсера или из `advance_stage` из-за
|
||||
парсинга.
|
||||
|
||||
## AC-5 — Тесты зелёные + новые юнит-тесты парсера
|
||||
|
||||
**Условие:** существующие тесты не сломаны; добавлены юнит-тесты парсера,
|
||||
покрывающие: findings есть / findings пусто / битый YAML(frontmatter) / только
|
||||
P3 (нет P0/P1).
|
||||
|
||||
- **PASS:** `python -m pytest tests/ -q` зелёный; `tests/test_review_parse.py`
|
||||
содержит как минимум кейсы: P0/P1 присутствуют → текст возвращён; нет
|
||||
findings/только P2-P3 → `""`; битый файл → `""`; отсутствующий путь → `""`;
|
||||
для test-report: FAIL-фрагмент извлечён / пустой отчёт → `""`.
|
||||
- **FAIL:** падение существующих тестов или отсутствие перечисленных кейсов.
|
||||
|
||||
## AC-6 — Retry-счётчик и rollback НЕ изменены по поведению
|
||||
|
||||
**Условие:** логика `_developer_retry_count`, `MAX_DEVELOPER_RETRIES = 3`,
|
||||
последовательность откатов и поля `AdvanceResult` (`rolled_back_to`,
|
||||
`enqueued_agent`, `enqueued_job_id`, `alerted`) идентичны прежним.
|
||||
|
||||
- **PASS:** существующие тесты `test_stage_engine.py` на rollback/retry зелёные;
|
||||
при 4-м заходе по-прежнему alert вместо enqueue; меняется только текст
|
||||
`task_desc`.
|
||||
- **FAIL:** изменилось число retry, порядок вызовов, или значения полей
|
||||
`AdvanceResult`.
|
||||
|
||||
## AC-7 — Out-of-scope не затронут
|
||||
|
||||
**Условие:** не изменены: `check_*` гейты, реестр `QG_CHECKS`, сигнатуры
|
||||
публичных функций (`advance_stage`, `_run_qg`, `check_*`), webhook-пути, формат
|
||||
Plane-комментариев.
|
||||
|
||||
- **PASS:** `git diff` не содержит изменений в `src/qg/checks.py` (логика
|
||||
гейтов), сигнатурах публичных функций, `src/webhooks/*`,
|
||||
`usage.build_status_comment`; `test_qg_registry_snapshot` зелёный.
|
||||
- **FAIL:** любое из перечисленного изменено.
|
||||
|
||||
## AC-8 — Документация и ADR обновлены (golden source)
|
||||
|
||||
**Условие:** правка ядра → заведён ADR (`06-adr/`), обновлён `CHANGELOG.md`, при
|
||||
необходимости — `docs/architecture/README.md`/`internals.md` (раздел Stage
|
||||
Engine / Откаты).
|
||||
|
||||
- **PASS:** присутствует `docs/work-items/ORCH-046/06-adr/ADR-001-*.md`; в
|
||||
`CHANGELOG.md` есть запись ORCH-046.
|
||||
- **FAIL:** ADR или запись в CHANGELOG отсутствуют.
|
||||
108
docs/work-items/ORCH-046/04-test-plan.yaml
Normal file
108
docs/work-items/ORCH-046/04-test-plan.yaml
Normal file
@@ -0,0 +1,108 @@
|
||||
work_item: ORCH-046
|
||||
description: >
|
||||
Тест-план для встраивания дословного текста findings reviewer/tester в
|
||||
task_desc при заворотах деву. Покрывает новый парсер src/review_parse.py
|
||||
(graceful, never-raise) и две rollback-ветки src/stage_engine.py.
|
||||
|
||||
tests:
|
||||
# --- Парсер review findings (extract_review_findings) -------------------
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "extract_review_findings возвращает дословный текст P0/P1 при их наличии в 12-review.md"
|
||||
module: tests/test_review_parse.py
|
||||
covers: [AC-1, AC-5]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "extract_review_findings возвращает '' когда есть только P2/P3 (нет must-fix P0/P1)"
|
||||
module: tests/test_review_parse.py
|
||||
covers: [AC-5]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "extract_review_findings возвращает '' для отсутствующего файла (несуществующий путь), без исключения"
|
||||
module: tests/test_review_parse.py
|
||||
covers: [AC-4]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "extract_review_findings возвращает '' для битого/пустого файла или markdown без секции ## Findings, без исключения"
|
||||
module: tests/test_review_parse.py
|
||||
covers: [AC-4, AC-5]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "extract_review_findings усекает очень длинные findings до лимита с маркером truncated"
|
||||
module: tests/test_review_parse.py
|
||||
covers: [AC-1]
|
||||
expected: PASS
|
||||
|
||||
# --- Парсер test failures (extract_test_failures) ----------------------
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "extract_test_failures извлекает релевантный фрагмент тела (Вывод pytest / FAIL-строки / Итог) из 13-test-report.md с result: FAIL"
|
||||
module: tests/test_review_parse.py
|
||||
covers: [AC-2, AC-5]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "extract_test_failures возвращает '' для отсутствующего файла, без исключения"
|
||||
module: tests/test_review_parse.py
|
||||
covers: [AC-4]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "extract_test_failures возвращает '' для битого/пустого отчёта (нет тела/секций), без исключения"
|
||||
module: tests/test_review_parse.py
|
||||
covers: [AC-4, AC-5]
|
||||
expected: PASS
|
||||
|
||||
# --- Интеграция со stage_engine (rollback task_desc) -------------------
|
||||
- id: TC-09
|
||||
type: integration
|
||||
description: "advance_stage: reviewer REQUEST_CHANGES -> в enqueue_job('developer') task_desc содержит дословные P0/P1 findings И ссылку на 12-review.md"
|
||||
module: tests/test_stage_engine.py
|
||||
covers: [AC-1, AC-3]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-10
|
||||
type: integration
|
||||
description: "advance_stage: tester check_tests_passed FAIL -> task_desc содержит reason + фрагмент 13-test-report.md И ссылку на файл"
|
||||
module: tests/test_stage_engine.py
|
||||
covers: [AC-2, AC-3]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-11
|
||||
type: integration
|
||||
description: "advance_stage: reviewer REQUEST_CHANGES при отсутствующем/битом 12-review.md -> graceful fallback, task_desc = прежняя ссылка-строка, без исключения"
|
||||
module: tests/test_stage_engine.py
|
||||
covers: [AC-4, AC-3]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-12
|
||||
type: integration
|
||||
description: "advance_stage: rollback/retry поведение неизменно — последовательность откатов, _developer_retry_count, alert на 4-й заход, поля AdvanceResult"
|
||||
module: tests/test_stage_engine.py
|
||||
covers: [AC-6]
|
||||
expected: PASS
|
||||
|
||||
# --- Регресс / неизменность out-of-scope ------------------------------
|
||||
- id: TC-13
|
||||
type: integration
|
||||
description: "Реестр QG_CHECKS не изменён (snapshot), гейты check_* нетронуты"
|
||||
module: tests/test_qg_registry_snapshot.py
|
||||
covers: [AC-7]
|
||||
expected: PASS
|
||||
|
||||
- id: TC-14
|
||||
type: integration
|
||||
description: "Полный регресс существующего набора зелёный: python -m pytest tests/ -q"
|
||||
module: tests/
|
||||
covers: [AC-5, AC-6, AC-7]
|
||||
expected: PASS
|
||||
@@ -0,0 +1,143 @@
|
||||
# ADR-001: дословный текст findings reviewer/tester встраивается в `task_desc` через отдельный graceful-парсер
|
||||
|
||||
- **Статус:** Accepted
|
||||
- **Дата:** 2026-06-06
|
||||
- **Задача:** ORCH-046
|
||||
- **Область:** ЯДРО `src/stage_engine.py` (rollback-ветки) + новый модуль `src/review_parse.py`. Общий прод-инстанс (orchestrator + enduro-trails), self-hosting.
|
||||
|
||||
## Контекст
|
||||
|
||||
При заворотах задачи на `development` (откат) `stage_engine` формирует `task_desc`,
|
||||
который попадает в `.task-dev.md` запускаемого developer-агента. В двух ветках
|
||||
`_handle_qg_failure_rollbacks` этот текст содержит **только ссылку на файл-артефакт**:
|
||||
|
||||
- reviewer REQUEST_CHANGES (`src/stage_engine.py` ~стр. 419) → `…Fix findings in docs/work-items/<id>/12-review.md`;
|
||||
- tester `check_tests_passed` FAIL (~стр. 455) → `…Fix failures described in docs/work-items/<id>/13-test-report.md`.
|
||||
|
||||
Developer-агент получает инструкцию «иди читай файл»; ключевые претензии (P0/P1
|
||||
ревьюера, причина падения тестера) теряются — агент повторяет ту же ошибку, и
|
||||
задача заворачивается снова. Это «испорченный телефон»: расход retry-бюджета
|
||||
(`MAX_DEVELOPER_RETRIES = 3`), токенов и простой конвейера (для всех проектов
|
||||
общего инстанса).
|
||||
|
||||
Ограничение из BRD/ТЗ (вариант A, выбран Owner): минимальная, низкорисковая
|
||||
правка ядра. Любая регрессия в формировании `task_desc` или (тем более)
|
||||
исключение в `advance_stage` останавливает конвейер ВСЕХ проектов — следовательно
|
||||
извлечение текста обязано быть полностью graceful.
|
||||
|
||||
## Решение
|
||||
|
||||
Встраивать **дословный текст ключевых замечаний** в `task_desc` при заворотах,
|
||||
сохраняя ссылку на полный файл как дополнительный контекст. Извлечение вынести в
|
||||
отдельный defensive-модуль, чтобы изолировать blast radius от ядра.
|
||||
|
||||
1. **Новый модуль `src/review_parse.py`** с двумя чистыми функциями чтения с диска:
|
||||
- `extract_review_findings(path: str) -> str` — дословные пункты P0/P1 из секции
|
||||
`## Findings` файла `12-review.md`;
|
||||
- `extract_test_failures(path: str) -> str` — релевантный фрагмент тела
|
||||
`13-test-report.md` (приоритет: `## Вывод pytest` → FAIL-строки `## Результаты`
|
||||
→ `## Итог`).
|
||||
- **Контракт «never raise»** (как `src/frontmatter.py` и
|
||||
`src/qg/checks.py::_parse_tests_verdict`): любая ошибка — нет файла, IOError,
|
||||
кривой markdown/YAML, нет секции — возвращает `""`. Логирование на
|
||||
`logger.debug` (`logging.getLogger("orchestrator.review_parse")`). Никаких
|
||||
сетевых вызовов.
|
||||
- Результат усекается модульными лимитами `MAX_FINDINGS_CHARS`,
|
||||
`MAX_FAILURES_CHARS` (≈2000) с маркером `…(truncated)`; полный контекст всегда
|
||||
остаётся в файле.
|
||||
|
||||
2. **Две ветки `_handle_qg_failure_rollbacks` в `src/stage_engine.py`** строят путь
|
||||
через `get_worktree_path(repo, branch)` (как `_check_review_approved_by_branch`),
|
||||
вызывают соответствующий парсер и:
|
||||
- если результат непустой — встраивают findings/фрагмент **дословно** под
|
||||
подзаголовком + оставляют ссылку как «полный контекст»;
|
||||
- если результат пустой — `task_desc` остаётся **как сейчас** (graceful fallback
|
||||
на ссылку-строку);
|
||||
- tester-ветка дополнительно всегда включает `reason` из гейта (он уже доступен).
|
||||
|
||||
3. **Изоляция ядра.** Меняется ТОЛЬКО содержимое строки `task_desc`, передаваемой в
|
||||
`enqueue_job`. Последовательность отката (`update_task_stage` →
|
||||
`notify_stage_change` → `plane_notify_stage` → [`set_issue_in_progress` для
|
||||
тестера] → проверка `_developer_retry_count` < `MAX_DEVELOPER_RETRIES` →
|
||||
`enqueue_job`/`send_telegram`), значения `AdvanceResult` (`rolled_back_to`,
|
||||
`enqueued_agent`, `enqueued_job_id`, `alerted`) и Plane-комментарии — без
|
||||
изменений.
|
||||
|
||||
### Почему отдельный модуль, а не inline в `stage_engine`
|
||||
|
||||
- Тестируемость: парсер покрывается юнит-тестами `tests/test_review_parse.py`
|
||||
изолированно от тяжёлого `advance_stage`.
|
||||
- Blast radius: вся парсинг-логика (и её исключения) физически отделена от
|
||||
hot-path ядра; ядро только подставляет строку и делает try-around-граничный
|
||||
fallback.
|
||||
- Согласованность с уже принятым паттерном defensive-парсеров
|
||||
(`frontmatter.py`).
|
||||
|
||||
### Почему НЕ переиспользуется `frontmatter.read_frontmatter_value`
|
||||
|
||||
Тот хелпер читает одиночное значение из YAML-frontmatter. Здесь нужно извлекать
|
||||
**тело markdown** (подсекции `## Findings`/`### P0`, фрагменты `## Вывод pytest`),
|
||||
а не frontmatter-ключ. Это другая задача парсинга; общий контракт «never raise»
|
||||
повторяется намеренно (как уже зафиксировано в ORCH-016/ADR-001 §5 — слияние
|
||||
парсеров отдельной задачей).
|
||||
|
||||
### Почему per-work-item ADR, а не глобальный
|
||||
|
||||
Изменение НЕ добавляет гейт/стадию/компонент и НЕ меняет топологию или реестр
|
||||
`QG_CHECKS` — это обогащение содержимого `task_desc` в существующих rollback-ветках
|
||||
плюс вспомогательный модуль. По прецеденту ORCH-047/ADR-001 такого класса правки
|
||||
фиксируются per-work-item ADR. Глобальный `docs/architecture/adr/` не требуется.
|
||||
|
||||
### Альтернативы (отклонены)
|
||||
|
||||
- **Inline-парсинг прямо в `stage_engine`** — отклонено: раздувает ядро, хуже
|
||||
тестируется, исключения ближе к hot-path.
|
||||
- **Менять промпты reviewer/tester, чтобы они сами клали суть в `task_desc`** —
|
||||
отклонено: `task_desc` формирует ядро, а не агент; зависит от дисциплины двух
|
||||
агентов вместо детерминированного кода; шире поверхность регрессии.
|
||||
- **Передавать весь файл целиком в `task_desc`** — отклонено: раздувает промпт
|
||||
developer-агента и стоимость токенов; теряется фокус на must-fix. Усечение по
|
||||
P0/P1 + лимит решает проблему «испорченного телефона» дешевле.
|
||||
|
||||
## Последствия
|
||||
|
||||
- **Плюс:** developer-агент видит суть претензий (P0/P1, причина FAIL) сразу в
|
||||
`.task-dev.md`; меньше повторных заворотов, экономия retry-бюджета и токенов на
|
||||
всех проектах общего инстанса.
|
||||
- **Плюс:** при битом/отсутствующем артефакте поведение не хуже текущего (ссылка
|
||||
сохраняется); ссылка на полный файл присутствует всегда (AC-3).
|
||||
- **Плюс:** изменение аддитивное — публичные сигнатуры (`advance_stage`, `_run_qg`,
|
||||
`check_*`), реестр `QG_CHECKS`, webhook-пути и `build_status_comment` не
|
||||
затрагиваются; снапшот `test_qg_registry_snapshot` остаётся зелёным (AC-7).
|
||||
- **Минус/ограничение:** парсинг тела markdown чувствительнее к формату артефактов,
|
||||
чем чтение одного frontmatter-ключа. Митигировано: распознавание P0/P1 устойчиво
|
||||
к регистру/тире; при несовпадении формата — пустой результат и fallback на
|
||||
ссылку (никогда не исключение).
|
||||
- **Минус:** усечение лимитом может обрезать длинные findings — приемлемо, полный
|
||||
контекст остаётся в файле, ссылка сохранена.
|
||||
- **Self-hosting риск:** правка ядра в общем прод-контейнере. Обязателен прогон
|
||||
`deploy-staging` (8501) перед прод-деплоем; прод-контейнер `orchestrator` (8500)
|
||||
не перезапускать в рамках разработки/тестинга. Граничный риск — исключение из
|
||||
парсера в `advance_stage`; закрыт контрактом «never raise» + юнит-кейсами на
|
||||
битый/пустой/отсутствующий ввод (AC-4, AC-5).
|
||||
|
||||
## Влияние на документацию (golden source)
|
||||
|
||||
В PR разработки (вместе с кодом) обновить:
|
||||
- `docs/architecture/README.md` — раздел **Stage Engine** / **Откаты**: упомянуть,
|
||||
что `task_desc` при заворотах reviewer/tester несёт дословные findings + ссылку,
|
||||
и новый модуль `src/review_parse.py` (defensive, never-raise).
|
||||
- `CHANGELOG.md` — запись ORCH-046.
|
||||
- `docs/architecture/internals.md` — по усмотрению reviewer, если детализируется
|
||||
поток отката.
|
||||
|
||||
## Связи
|
||||
|
||||
- BRD/ТЗ/AC: `docs/work-items/ORCH-046/01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`.
|
||||
- Образцы паттерна «never raise»: `src/frontmatter.py`,
|
||||
`src/qg/checks.py::_parse_tests_verdict`.
|
||||
- Каноны артефактов: `.openclaw/agents/reviewer.md` (`12-review.md` `## Findings`),
|
||||
`.openclaw/agents/tester.md` (`13-test-report.md` `result:` + тело).
|
||||
- Прецедент per-work-item ADR на правку парсинга: ORCH-047/ADR-001.
|
||||
- Технические риски: `docs/work-items/ORCH-046/10-tech-risks.md`.
|
||||
- Staging-страховка: `docs/architecture/adr/adr-0003-staging-gate.md`.
|
||||
29
docs/work-items/ORCH-046/10-tech-risks.md
Normal file
29
docs/work-items/ORCH-046/10-tech-risks.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Технические риски — ORCH-046
|
||||
|
||||
Work Item ID: ORCH-046
|
||||
Stage: architecture
|
||||
Author: architect
|
||||
Date: 2026-06-06
|
||||
|
||||
Связано: `06-adr/ADR-001-embed-findings-in-task-desc.md`.
|
||||
|
||||
| # | Риск | Вероятн. | Влияние | Митигация | Контроль (AC/тест) |
|
||||
|---|------|----------|---------|-----------|--------------------|
|
||||
| R-1 | Исключение из парсера всплывает в `advance_stage` → встаёт конвейер ВСЕХ проектов (self-hosting, общий инстанс) | Низк. | **Критич.** | Контракт «never raise» в `review_parse.py`; вызов в `stage_engine` обёрнут так, что пустой результат → fallback на прежнюю ссылку-строку | AC-4; юнит-кейсы «нет файла / битый YAML / пустой / только P3» в `tests/test_review_parse.py` |
|
||||
| R-2 | Регрессия в последовательности отката или полях `AdvanceResult` (меняется не только `task_desc`) | Низк. | Высок. | Жёсткий инвариант ТЗ §3.3: трогать ТОЛЬКО строку `task_desc`; порядок вызовов и условия retry неизменны | AC-6; существующие `tests/test_stage_engine.py` (rollback/retry) зелёные |
|
||||
| R-3 | Парсер чувствителен к формату артефактов: дрейф `12-review.md`/`13-test-report.md` → пустой результат | Сред. | Низк. | Распознавание P0/P1 устойчиво к регистру/тире; при несовпадении → `""` + fallback на ссылку (деградация, не отказ) | AC-1/AC-2/AC-4 |
|
||||
| R-4 | Раздувание `task_desc` длинными findings → рост стоимости/потеря фокуса developer-агента | Сред. | Низк. | Лимиты `MAX_FINDINGS_CHARS`/`MAX_FAILURES_CHARS` (~2000) + маркер `…(truncated)`; only P0/P1 (P2/P3 отброшены) | AC-1; проверка усечения в юнит-тестах |
|
||||
| R-5 | Случайный выход за out-of-scope (правка `check_*`, `QG_CHECKS`, сигнатур, webhooks, `build_status_comment`) | Низк. | Сред. | Явный out-of-scope в ТЗ §4/§6; ревью diff | AC-7; `test_qg_registry_snapshot` зелёный |
|
||||
| R-6 | Прод-деплой self без страховки staging | Низк. | **Критич.** | Обязательная стадия `deploy-staging` (8501); прод `orchestrator` (8500) не рестартить в разработке/тестинге | adr-0003; стадийный гейт `check_staging_status` |
|
||||
| R-7 | Дублирование defensive-парсинга (3-й модуль рядом с `frontmatter.py` и `_parse_tests_verdict`) → техдолг | Сред. | Низк. | Осознанно принято (как ORCH-016/ADR-001 §5): малый blast radius важнее DRY; слияние парсеров — отдельная follow-up задача | — (техдолг, не блокер) |
|
||||
|
||||
## Заметки
|
||||
|
||||
- **Граничный try в ядре.** Даже при контракте «never raise» в `review_parse`,
|
||||
вызов в `stage_engine` следует считать недоверенным: подстановка результата в
|
||||
`task_desc` не должна зависеть от внутренней корректности парсера. Fallback на
|
||||
ссылку-строку обязателен и при пустом результате, и при любой неожиданности.
|
||||
- **Эскалация не требуется.** Изменение укладывается в принципы (минимум
|
||||
зависимостей, raw-парсинг без новых либ, без новых компонентов/стадий/QG).
|
||||
Лейбл `arch:major-change` НЕ ставится; возврат в Анализ не требуется — ТЗ
|
||||
реализуемо без нарушения принципов.
|
||||
83
docs/work-items/ORCH-046/12-review.md
Normal file
83
docs/work-items/ORCH-046/12-review.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-046
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-046
|
||||
|
||||
## Summary
|
||||
|
||||
Правка ядра «вариант A»: при заворотах на `development` `task_desc` теперь несёт
|
||||
**дословный must-fix текст** (P0/P1 ревьюера, причина FAIL тестера) вместо одной
|
||||
ссылки на файл. Извлечение вынесено в новый defensive-модуль `src/review_parse.py`
|
||||
с контрактом «never raise»; две rollback-ветки `_handle_qg_failure_rollbacks`
|
||||
встраивают текст и сохраняют ссылку как «Полный контекст», при пустом/битом
|
||||
артефакте — graceful-фоллбэк на прежнюю строку.
|
||||
|
||||
Реализация полностью соответствует ТЗ (`02-trz.md`), ADR-001 и всем критериям
|
||||
приёмки. Документация обновлена в этом же PR. Тесты зелёные (`461 passed`).
|
||||
|
||||
Проверено по осям:
|
||||
|
||||
**1. Соответствие ТЗ.** Сигнатуры `extract_review_findings`/`extract_test_failures`
|
||||
точно как в ТЗ §2; never-raise, логирование на `logger.debug`, модульные лимиты
|
||||
`MAX_FINDINGS_CHARS`/`MAX_FAILURES_CHARS`, отбрасывание frontmatter, устойчивость
|
||||
P0/P1-заголовков к регистру/тире, пропуск плейсхолдеров `(если есть)`/`<…>`,
|
||||
приоритет источников тестера (`## Вывод pytest` → FAIL-строки `## Результаты` →
|
||||
`## Итог`). Префикс `task_desc`, `reason` в tester-ветке, ссылка-фоллбэк — как
|
||||
предписано §3. API/БД/QG не тронуты (§4–6).
|
||||
|
||||
**2. Соответствие ADR-001.** Отдельный модуль (blast radius), путь через
|
||||
`get_worktree_path`, изоляция ядра (меняется только строка `task_desc`),
|
||||
последовательность отката и поля `AdvanceResult` сохранены. Per-work-item ADR
|
||||
обоснован. Реализация ⇄ решение совпадают.
|
||||
|
||||
**3. Качество кода.** Docstrings на всех публичных функциях; defensive `_read`
|
||||
ловит `OSError`, внешний `try/except Exception` в обоих экстракторах гарантирует
|
||||
never-raise (подтверждено кейсом на directory-path). Регэксп `_P01_HEADER_RE`
|
||||
корректно отсекает ложные совпадения (`P05` и т.п.). Код читабелен, без дублей.
|
||||
|
||||
**4. Качество тестов.** `tests/test_review_parse.py` покрывает TC-01..08 (findings
|
||||
есть / только P2-P3 / нет файла / битый YAML / усечение / регистр-тире / directory).
|
||||
`tests/test_stage_engine.py::TestRollbackTaskDescEmbedding` проверяет встраивание
|
||||
в обе ветки, graceful-фоллбэк, неизменность retry/rollback на 4-м заходе (alert
|
||||
вместо enqueue). Содержательные, не тривиальные.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- [ ] (нет)
|
||||
|
||||
### P1 — Must fix
|
||||
- [ ] (нет)
|
||||
|
||||
### P2 — Should fix
|
||||
- [ ] (нет)
|
||||
|
||||
## Соответствие критериям приёмки
|
||||
|
||||
- AC-1 (дословные P0/P1 в `task_desc`) — PASS: `Findings (P0/P1):\n{findings}`.
|
||||
- AC-2 (причина тестера: `reason` + фрагмент тела) — PASS: `Причина: {reason}` + `Детали:`.
|
||||
- AC-3 (ссылка на полный файл сохранена) — PASS: «Полный контекст»/fallback-ссылка в обеих ветках.
|
||||
- AC-4 (graceful never-raise) — PASS: `""`→ссылка-фоллбэк, исключений нет (тесты TC-03/04/07/08, directory-path).
|
||||
- AC-5 (тесты зелёные + новые юнит-тесты) — PASS: `461 passed`; все перечисленные кейсы присутствуют.
|
||||
- AC-6 (retry/rollback не изменены) — PASS: TC-12 + существующие rollback-тесты зелёные.
|
||||
- AC-7 (out-of-scope не затронут) — PASS: diff не касается `src/qg/checks.py`, `src/webhooks/*`, `usage.py`, `stages.py`, `main.py`; сигнатуры публичных функций не менялись.
|
||||
- AC-8 (документация + ADR) — PASS: ADR-001 заведён, `CHANGELOG.md` и `docs/architecture/README.md` обновлены.
|
||||
|
||||
## Документация
|
||||
|
||||
Обновлена корректно и в том же PR (golden source соблюдён):
|
||||
- `docs/work-items/ORCH-046/06-adr/ADR-001-embed-findings-in-task-desc.md` — заведён (правка ядра).
|
||||
- `CHANGELOG.md` — запись ORCH-046 в `[Unreleased] / Added`.
|
||||
- `docs/architecture/README.md` — добавлен компонент **Review/Test Parsers** и раздел **Обогащение `task_desc` при заворотах (ORCH-046)**.
|
||||
|
||||
Изменение `src/` сопровождено обновлением документации — требование п.4/п.6 правил
|
||||
агентов выполнено.
|
||||
|
||||
## Примечание (self-hosting)
|
||||
Правка ядра в общем прод-инстансе. Перед прод-деплоем обязательна стадия
|
||||
`deploy-staging` (8501) согласно ADR-001 / CLAUDE.md — это страховка следующих
|
||||
стадий, не блокер ревью.
|
||||
92
docs/work-items/ORCH-046/13-test-report.md
Normal file
92
docs/work-items/ORCH-046/13-test-report.md
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-046
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-046
|
||||
|
||||
Встраивание дословного must-fix текста findings reviewer/tester в `task_desc`
|
||||
при заворотах на `development` (новый модуль `src/review_parse.py` + две
|
||||
rollback-ветки `src/stage_engine.py`).
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3 (asyncio mode=AUTO)
|
||||
- Ветка: feature/ORCH-046-stage-engine-pass-reviewer-tes
|
||||
- Дата: 2026-06-06
|
||||
|
||||
## Результаты
|
||||
|
||||
| TC ID | Описание | Покрывает | Результат |
|
||||
|-------|----------|-----------|-----------|
|
||||
| TC-01 | `extract_review_findings` возвращает дословный P0/P1 текст | AC-1, AC-5 | PASS |
|
||||
| TC-02 | `extract_review_findings` → `""` при только P2/P3 | AC-5 | PASS |
|
||||
| TC-03 | `extract_review_findings` → `""` для отсутствующего файла | AC-4 | PASS |
|
||||
| TC-04 | `extract_review_findings` → `""` для битого/без секции файла | AC-4, AC-5 | PASS |
|
||||
| TC-05 | `extract_review_findings` усекает длинный текст с маркером truncated | AC-1 | PASS |
|
||||
| TC-06 | `extract_test_failures` извлекает фрагмент тела (Вывод pytest/FAIL/Итог) | AC-2, AC-5 | PASS |
|
||||
| TC-07 | `extract_test_failures` → `""` для отсутствующего файла | AC-4 | PASS |
|
||||
| TC-08 | `extract_test_failures` → `""` для битого/пустого отчёта | AC-4, AC-5 | PASS |
|
||||
| TC-09 | reviewer REQUEST_CHANGES → `task_desc` содержит P0/P1 + ссылку | AC-1, AC-3 | PASS |
|
||||
| TC-10 | tester FAIL → `task_desc` содержит reason + фрагмент + ссылку | AC-2, AC-3 | PASS |
|
||||
| TC-11 | graceful fallback при отсутствующем/битом файле (обе ветки) | AC-4, AC-3 | PASS |
|
||||
| TC-12 | rollback/retry поведение неизменно (alert на 4-й заход, поля AdvanceResult) | AC-6 | PASS |
|
||||
| TC-13 | Реестр `QG_CHECKS` не изменён (snapshot), гейты нетронуты | AC-7 | PASS |
|
||||
| TC-14 | Полный регресс существующего набора зелёный | AC-5, AC-6, AC-7 | PASS |
|
||||
|
||||
Сопоставление TC ↔ тесты:
|
||||
- TC-01..08 → `tests/test_review_parse.py` (`TestExtractReviewFindings`, `TestExtractTestFailures`), 14 кейсов, все PASS.
|
||||
- TC-09..12 → `tests/test_stage_engine.py::TestRollbackTaskDescEmbedding`, все PASS.
|
||||
- TC-13 → `tests/test_qg_registry_snapshot.py` (registry/callables/transitions snapshot), все PASS.
|
||||
- TC-14 → полный прогон `pytest tests/` → **461 passed**.
|
||||
|
||||
## Smoke test API (read-only, прод-инстанс не затронут)
|
||||
|
||||
| Endpoint | HTTP | Ответ |
|
||||
|----------|------|-------|
|
||||
| GET /health | 200 | `{"status":"ok","service":"orchestrator"}` |
|
||||
| GET /status | 200 | active_tasks включает task 37 (ORCH-046, stage=testing) |
|
||||
| GET /queue | 200 | counts: queued=0, running=1, failed=0; breaker=closed; preflight_ok=true |
|
||||
|
||||
> `curl` в окружении отсутствует — smoke выполнен через `urllib`. Только GET-запросы,
|
||||
> деструктивных операций над прод-контейнером не выполнялось (self-hosting safety).
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
```
|
||||
============================= test session starts ==============================
|
||||
platform linux -- Python 3.12.13, pytest-8.3.3, pluggy-1.6.0
|
||||
rootdir: .../feature_ORCH-046-stage-engine-pass-reviewer-tes
|
||||
configfile: pytest.ini
|
||||
plugins: anyio-4.13.0, asyncio-0.23.8
|
||||
asyncio: mode=Mode.AUTO
|
||||
...
|
||||
======================== 461 passed, 1 warning in 7.59s ========================
|
||||
```
|
||||
|
||||
Прицельный прогон ORCH-046 (`test_review_parse.py` + `test_stage_engine.py` +
|
||||
`test_qg_registry_snapshot.py`): **53 passed**.
|
||||
|
||||
Единственный warning — преэкзистентный `PydanticDeprecatedSince20` в `src/config.py`
|
||||
(не связан с ORCH-046).
|
||||
|
||||
## Покрытие критериев приёмки
|
||||
|
||||
| AC | Критерий | Подтверждение | Статус |
|
||||
|----|----------|---------------|--------|
|
||||
| AC-1 | Дословные P0/P1 в `task_desc` | TC-01, TC-09 | PASS |
|
||||
| AC-2 | Причина тестера (reason + фрагмент) в `task_desc` | TC-06, TC-10 | PASS |
|
||||
| AC-3 | Ссылка на полный файл сохранена | TC-09, TC-10, TC-11 | PASS |
|
||||
| AC-4 | Парсер graceful (never-raise) | TC-03, TC-04, TC-07, TC-08, TC-11 | PASS |
|
||||
| AC-5 | Тесты зелёные + новые юнит-тесты | TC-14 (461 passed) | PASS |
|
||||
| AC-6 | Retry/rollback не изменены | TC-12 | PASS |
|
||||
| AC-7 | Out-of-scope не затронут | TC-13 | PASS |
|
||||
| AC-8 | Документация + ADR | проверено reviewer (12-review.md, APPROVED) | PASS |
|
||||
|
||||
## Итог
|
||||
|
||||
**PASS** — все 14 TC из тест-плана зелёные, полный регресс 461 passed,
|
||||
smoke API 200 по всем эндпоинтам, прод-инстанс здоров. Все критерии приёмки
|
||||
выполнены. Задача готова к стадии `deploy-staging` (8501) — обязательной
|
||||
страховке self-hosting перед прод-деплоем.
|
||||
90
docs/work-items/ORCH-046/15-staging-log.md
Normal file
90
docs/work-items/ORCH-046/15-staging-log.md
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
staging_status: FAILED
|
||||
timestamp: 2026-06-06T04:47:45Z
|
||||
base_url: http://localhost:8501
|
||||
mode: stub
|
||||
result: 9/10 checks PASS
|
||||
failed_checks: [B6]
|
||||
---
|
||||
|
||||
# Staging Gate Log — ORCH-046
|
||||
|
||||
Staging test suite **FAILED**. Exit code 1 (9/10 checks passed).
|
||||
|
||||
## Verdict
|
||||
|
||||
The staging gate is **red**: one check failed (`B6`). Per pipeline policy a
|
||||
non-zero staging suite is `staging_status: FAILED` → rollback to `development`.
|
||||
|
||||
## Failed check
|
||||
|
||||
```
|
||||
✗ FAIL B6 Registry: sandbox present, prod ET/ORCH absent
|
||||
[sandbox=NO, prod-ET=YES(BAD!), prod-ORCH=YES(BAD!)]
|
||||
```
|
||||
|
||||
**What it means.** The staging container's project registry is **not isolated**:
|
||||
it sees the production projects `enduro-trails` (ET) and `orchestrator` (ORCH),
|
||||
and the `orchestrator-sandbox` (SANDBOX) project is **absent**. This violates the
|
||||
hard isolation invariant for staging (`docs/operations/INFRA.md`: «Staging видит
|
||||
ТОЛЬКО `orchestrator-sandbox` (SANDBOX) — изоляция»). The staging gate exists
|
||||
precisely to catch this class of safety breach before any prod deploy of the
|
||||
self-hosting orchestrator.
|
||||
|
||||
**Triage note (for humans).** This is a **staging environment / configuration**
|
||||
issue — the staging container's `ORCH_PROJECTS_JSON` is pointing at the prod
|
||||
registry instead of the sandbox-only registry. It is **not** a code regression
|
||||
introduced by the ORCH-046 changeset (which only touches `src/review_parse.py`
|
||||
and rollback `task_desc` enrichment). However, the gate is authoritative and red,
|
||||
so the work item cannot pass to `deploy`. Fix the staging `.env.staging` /
|
||||
`ORCH_PROJECTS_JSON` to expose only SANDBOX, re-run the staging suite, and the
|
||||
gate will go green.
|
||||
|
||||
> ⚠️ Safety note: the first run aborted at `A3` because `ORCH_STAGING` was not
|
||||
> set in the runner env (the script's guard against accidentally hitting prod).
|
||||
> Re-run with `ORCH_STAGING=true` against the staging URL (8501) executed the
|
||||
> full suite. Prod (8500) was never touched.
|
||||
|
||||
## Full suite output
|
||||
|
||||
```
|
||||
============================================================
|
||||
ORCH-33 Staging Check Suite
|
||||
base_url : http://localhost:8501
|
||||
mode : stub
|
||||
utc_time : 2026-06-06T04:47:27.628664+00:00
|
||||
============================================================
|
||||
|
||||
[Block A] SMOKE
|
||||
✓ PASS A1 GET /health → 200 status=ok [HTTP 200, body={'status': 'ok', 'service': 'orchestrator'}]
|
||||
✓ PASS A2 GET /queue → 200 with counts/max_concurrency/resilience [HTTP 200, keys=['counts', 'max_concurrency', 'poll_interval', 'resilience', 'recent']]
|
||||
✓ PASS A3 ORCH_STAGING=true (not prod) [ORCH_STAGING=true]
|
||||
|
||||
[Block B] ACCESS
|
||||
✓ PASS B4 Plane: sandbox project accessible [HTTP 200, found 5 project(s), sandbox=YES]
|
||||
✓ PASS B5 Gitea: orchestrator-sandbox accessible, push=true [HTTP 200, permissions={'admin': True, 'push': True, 'pull': True}]
|
||||
✗ FAIL B6 Registry: sandbox present, prod ET/ORCH absent [sandbox=NO, prod-ET=YES(BAD!), prod-ORCH=YES(BAD!)]
|
||||
|
||||
[Block C] E2E (mode=stub)
|
||||
· C7: Creating issue in SANDBOX project...
|
||||
✓ PASS C7 Create issue in Plane SANDBOX [HTTP 201, issue_id=2fcbcb0c-1b29-4b76-8ba8-a8fe42cebdb4]
|
||||
· C8: Triggering pipeline via POST /webhook/plane ...
|
||||
· Using HMAC signature (secret len=40)
|
||||
✓ PASS C8 Trigger pipeline via /webhook/plane [HTTP 200, resp={'status': 'accepted'}]
|
||||
· C9a: Polling for branch in orchestrator-sandbox (up to 60s)...
|
||||
✓ PASS C9a Branch appears in orchestrator-sandbox [branch=feature/SANDBOX-011-staging-check-e2e-20260606t044]
|
||||
· C9b: Checking staging job queue for analyst job (up to 30s)...
|
||||
· (Plane comment check skipped: bot-tokens not added to SANDBOX project)
|
||||
✓ PASS C9b Analyst job enqueued in staging queue [job_id=7, status=queued, agent=analyst]
|
||||
|
||||
[CLEANUP]
|
||||
✓ PASS CLEANUP: deleted branch 'feature/SANDBOX-011-staging-check-e2e-20260606t044' (HTTP 204)
|
||||
✓ PASS CLEANUP: deleted Plane issue 2fcbcb0c-1b29-4b76-8ba8-a8fe42cebdb4 (HTTP 204)
|
||||
· CLEANUP DB: no task row found for plane_id=2fcbcb0c-1b29-4b76-8ba8-a8fe42cebdb4
|
||||
· CLEANUP DB dedup: no such table: events_dedup
|
||||
|
||||
============================================================
|
||||
RESULT: 9/10 checks PASS
|
||||
============================================================
|
||||
EXIT_CODE=1
|
||||
```
|
||||
7
docs/work-items/ORCH-048/00-business-request.md
Normal file
7
docs/work-items/ORCH-048/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: staging B6 check reads registry from host worktree, not staging container
|
||||
|
||||
Work Item ID: ORCH-048
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
86
docs/work-items/ORCH-048/01-brd.md
Normal file
86
docs/work-items/ORCH-048/01-brd.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# 01 — Business Requirements Document (BRD)
|
||||
|
||||
**Work Item:** ORCH-048
|
||||
**Title:** staging B6 check reads registry from host worktree, not staging container
|
||||
**Stage:** analysis
|
||||
**Author:** analyst
|
||||
**Date:** 2026-06-06
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
`scripts/staging_check.py` — suite живых проверок staging-стенда orchestrator (порт 8501, ADR-0003). Деплоер запускает его на стадии `deploy-staging` и пишет `staging_status:` в `15-staging-log.md`. FAIL любого чека = откат на `development`.
|
||||
|
||||
Блок B содержит проверку **B6 «Registry: sandbox present, prod ET/ORCH absent»** — она должна подтверждать, что в staging-реестре проектов есть только sandbox-проект и НЕТ боевых проектов (enduro-trails / orchestrator). Это страховка изоляции: staging не должен обслуживать прод-проекты.
|
||||
|
||||
**B6 даёт ложный FAIL** (`prod-ET=YES(BAD!)`, `prod-ORCH=YES(BAD!)`), хотя сама изоляция реестра в staging РАБОТАЕТ корректно.
|
||||
|
||||
### Root cause (подтверждён прямым запуском, Стрим, 06.06)
|
||||
|
||||
- Внутри контейнера `orchestrator-staging` `known_plane_project_ids()` корректно отдаёт `count=1, sandbox=True, ET=False, ORCH=False`. `.env.staging` верно задаёт `ORCH_PROJECTS_JSON` = только sandbox. **Изоляция реестра исправна.**
|
||||
- Все остальные чеки (A1–A3, B4, B5, блок C E2E) обращаются к работающему staging-инстансу по HTTP и **зелёные**.
|
||||
- **B6 — единственный чек, который не ходит по HTTP, а импортирует Python-код локально.** В блоке B6 (строки ~263–284) выполняется:
|
||||
```python
|
||||
sys.path.insert(0, "/repos/orchestrator") # ХОСТ-worktree
|
||||
importlib.reload(sys.modules["src.projects"]) # подхватывает env ТЕКУЩЕГО процесса
|
||||
from src.projects import known_plane_project_ids
|
||||
```
|
||||
- Деплоер по факту запускает скрипт **с хоста** (`.openclaw/agents/deployer.md`: `python3 scripts/staging_check.py --base-url http://localhost:8501`). В env хост-процесса `ORCH_PROJECTS_JSON` НЕ задан → `src.projects` грузит встроенный `_DEFAULT_PROJECTS` (ET + ORCH) → `known_plane_project_ids()` возвращает боевые id → **ложный FAIL**.
|
||||
- Иными словами, B6 проверяет реестр НЕ того окружения, реестр которого реально использует staging-инстанс. Гипотеза деплоера про misconfig staging-контейнера — **опровергнута**.
|
||||
|
||||
## 2. Бизнес-цель
|
||||
|
||||
B6 должен достоверно отражать реестр проектов **именно работающего staging-инстанса** (изолированное окружение), а не реестр, восстановленный из локального импорта в произвольном process-env. При этом B6 обязан по-прежнему ловить реальное нарушение изоляции.
|
||||
|
||||
## 3. Заинтересованные стороны
|
||||
|
||||
| Роль | Интерес |
|
||||
|------|---------|
|
||||
| Deployer-агент | Достоверный сигнал staging-гейта; нет ложных откатов на development |
|
||||
| Owner / прод | Изоляция staging от прод-проектов реально проверяется (не ложно-зелёная и не ложно-красная) |
|
||||
| Self-hosting pipeline | `deploy-staging` — обязательная страховка перед прод-деплоем орка; ложный FAIL блокирует доставку всех ORCH-задач |
|
||||
|
||||
## 4. Объём (Scope)
|
||||
|
||||
### В объёме
|
||||
- Исправление блока B6 в `scripts/staging_check.py`, чтобы он читал реестр в окружении staging-инстанса.
|
||||
- Тест на корректность B6: оба исхода (PASS при чистой изоляции; FAIL при попадании прод-проекта в staging-реестр).
|
||||
- Обновление документации B6 (`docs/operations/STAGING_CHECK.md`, при необходимости `docs/architecture/README.md`/CHANGELOG) в том же PR.
|
||||
|
||||
### Вне объёма (НЕ ТРОГАТЬ)
|
||||
- `src/projects.py` — реестр работает корректно.
|
||||
- `.env` / `.env.staging` — конфигурация верна.
|
||||
- Прод-логика оркестратора.
|
||||
- Остальные staging-чеки B1–B5 и блок C E2E — зелёные.
|
||||
|
||||
## 5. Бизнес-требования
|
||||
|
||||
| ID | Требование |
|
||||
|----|------------|
|
||||
| BR-1 | B6 на staging даёт PASS (`sandbox=YES`, `prod-ET=NO`, `prod-ORCH=NO`), читая реестр из окружения staging-инстанса, а не из локального импорта хост-worktree. |
|
||||
| BR-2 | B6 по-прежнему детектирует реальное нарушение изоляции: если бы прод-проект реально попал в staging-реестр, B6 обязан выдать FAIL. |
|
||||
| BR-3 | Остальные staging-чеки не сломаны; `src/projects.py` и `.env*` не изменяются. |
|
||||
| BR-4 | Существующие unit-тесты остаются зелёными (`pytest tests/ -q`). |
|
||||
| BR-5 | Документация B6 обновлена в том же PR (golden source). |
|
||||
|
||||
## 6. Допущения и ограничения
|
||||
|
||||
- Решение должно быть минимально инвазивным и не затрагивать прод-логику.
|
||||
- Скрипт `scripts/staging_check.py` использует только stdlib (нет `requests`/`httpx`) — это конвенция файла, её нужно сохранить.
|
||||
- Способ запуска suite может варьироваться (с хоста / `docker exec` внутри контейнера) — выбранное решение должно быть корректным для канонического способа запуска деплоером и задокументировано.
|
||||
|
||||
## 7. Критерий успеха (бизнес)
|
||||
|
||||
- staging-прогон `scripts/staging_check.py` → **B6 PASS** при работающей изоляции.
|
||||
- При искусственно нарушенной изоляции → **B6 FAIL** (проверяется тестом, без реального изменения staging).
|
||||
- `python -m pytest tests/ -q` — зелёный.
|
||||
|
||||
## 8. Открытые вопросы (для архитектора)
|
||||
|
||||
Бизнес-запрос предлагает три варианта реализации (выбор за архитектором, см. 02-trz §4):
|
||||
- (а) B6 читает реестр через HTTP-эндпоинт staging-инстанса;
|
||||
- (б) B6 выполняет проверку через subprocess в окружении staging-контейнера (`docker exec`);
|
||||
- (в) staging_check запускается ВНУТРИ staging-контейнера и читает собственный process-env (убрать host-path хак).
|
||||
|
||||
Предпочтение бизнес-запроса: минимально инвазивный вариант, не трогающий прод-логику.
|
||||
118
docs/work-items/ORCH-048/02-trz.md
Normal file
118
docs/work-items/ORCH-048/02-trz.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# 02 — Техническое задание (ТЗ / TRZ)
|
||||
|
||||
**Work Item:** ORCH-048
|
||||
**Title:** staging B6 check reads registry from host worktree, not staging container
|
||||
**Stage:** analysis
|
||||
**Author:** analyst
|
||||
**Date:** 2026-06-06
|
||||
|
||||
> Это ТЗ фиксирует требования и инварианты. Выбор одного из трёх архитектурных вариантов (§4) — за архитектором (ADR). Анализ варианты НЕ выбирает.
|
||||
|
||||
---
|
||||
|
||||
## 1. Задействованные модули
|
||||
|
||||
| Путь | Роль | Характер изменений |
|
||||
|------|------|--------------------|
|
||||
| `scripts/staging_check.py` | Suite живых staging-проверок; блок B6 (~строки 263–284) | **Изменяется** — переписать механику получения реестра в B6 |
|
||||
| `tests/` (новый файл, напр. `tests/test_staging_check_b6.py`) | Unit-тест корректности B6 | **Создаётся** |
|
||||
| `docs/operations/STAGING_CHECK.md` | Док запуска suite | **Обновляется** (описание B6 + способ запуска) |
|
||||
| `docs/architecture/README.md` / `CHANGELOG.md` | Golden source | **Обновляется** при необходимости |
|
||||
|
||||
### НЕ изменять (жёсткий инвариант scope)
|
||||
- `src/projects.py` — реестр работает корректно.
|
||||
- `.env`, `.env.staging`, `.env.example` — конфиг верен.
|
||||
- Прод-логику оркестратора (`src/main.py` прод-роуты, `src/webhooks/*`, `src/stage_engine.py`, `src/qg/*`) — кроме случая варианта (а), если архитектор решит добавить read-only эндпоинт (см. §4а, отдельно обоснованный риск).
|
||||
- Блоки A1–A3, B4, B5 и блок C E2E в `staging_check.py`.
|
||||
|
||||
## 2. Текущее поведение (то, что чиним)
|
||||
|
||||
Блок B6 (`scripts/staging_check.py`):
|
||||
```python
|
||||
sys.path.insert(0, "/repos/orchestrator") # хост-worktree path
|
||||
import importlib
|
||||
if "src.projects" in sys.modules:
|
||||
importlib.reload(sys.modules["src.projects"]) # перечитывает env ТЕКУЩЕГО процесса
|
||||
from src.projects import known_plane_project_ids
|
||||
known = known_plane_project_ids()
|
||||
```
|
||||
Проблема: реестр строится из `ORCH_PROJECTS_JSON` **process-env того процесса, в котором исполняется скрипт**. При запуске деплоером с хоста (`python3 scripts/staging_check.py --base-url http://localhost:8501`) переменная не задана → `_DEFAULT_PROJECTS` (ET+ORCH) → ложный FAIL. B6 не отражает реестр работающего staging-инстанса.
|
||||
|
||||
## 3. Требуемое поведение (контракт B6)
|
||||
|
||||
| ID | Требование |
|
||||
|----|------------|
|
||||
| TR-1 | B6 определяет набор «известных staging-инстансу Plane project id» из источника, который **гарантированно отражает окружение работающего staging-инстанса** (порт 8501 / контейнер `orchestrator-staging`), а не из локального импорта в process-env скрипта. |
|
||||
| TR-2 | B6 PASS ⟺ `SANDBOX_PROJECT_ID ∈ known` И `PROD_ET_PROJECT_ID ∉ known` И `PROD_ORCH_PROJECT_ID ∉ known`. Идентификаторы — те же константы, что уже в скрипте. |
|
||||
| TR-3 | B6 сохраняет формат вывода `Results.add(label, passed, detail)` с человекочитаемым detail (`sandbox=…, prod-ET=…, prod-ORCH=…`). |
|
||||
| TR-4 | При недоступности источника реестра B6 даёт **детерминированный FAIL** с понятным detail (не падает с необработанным исключением, не даёт ложный PASS). |
|
||||
| TR-5 | Скрипт остаётся на stdlib (без сторонних зависимостей), если выбранный вариант это допускает. |
|
||||
| TR-6 | Удаляется зависимость B6 от хардкод-пути `/repos/orchestrator` для построения реестра (host-path хак), несовместимого с целью проверки. |
|
||||
|
||||
## 4. Варианты реализации — РЕШЕНИЕ ВЛАДЕЛЬЦА (обязательно)
|
||||
|
||||
> **РЕШЕНИЕ ПРИНЯТО ВЛАДЕЛЬЦЕМ ПРОЕКТА (Слава, 06.06): выбран ВАРИАНТ (в).**
|
||||
> Архитектор НЕ выбирает заново — он фиксирует вариант (в) в ADR с обоснованием ниже.
|
||||
>
|
||||
> ### Почему (в), а НЕ (а) и НЕ (б)
|
||||
> - **(а) HTTP-эндпоинт `GET /projects`** — ОТКЛОНЁН. Порождает «курицу-яйцо»: B6 ходит на эндпоинт **работающего** staging-инстанса, а эндпоинт запечён в Docker-образ → на первом прогоне его в живом инстансе ещё нет (404) → ложный FAIL → откат. Требует ручного bootstrap-деплоя. Это ровно тот класс поломки автономности, который мы устраняем. (Подтверждено на проде 06.06: `GET /projects` на 8501 → 404 → deploy-staging FAILED.)
|
||||
> - **(б) `docker exec` subprocess** — ОТКЛОНЁН. Хрупкое экранирование (см. `docs/history/LESSONS_2026-06-05.md`), зависимость от docker-CLI и имени контейнера.
|
||||
> - **(в) запуск suite ВНУТРИ staging-контейнера + чтение собственного process-env** — ВЫБРАН. B6 не зависит от того, что отдаёт инстанс по HTTP; `staging_check.py` берётся из mount (свежий код сразу, без ребилда образа); реестр читается из env самого `orchestrator-staging`. **Курицы-яйца нет ни на первом прогоне, ни в будущем.** Автономность не ломается.
|
||||
>
|
||||
> ### Что обязан зафиксировать архитектор в ADR (вариант в)
|
||||
> 1. Убрать из B6 host-path хак `sys.path.insert(0, "/repos/orchestrator")` и `importlib.reload(src.projects)`.
|
||||
> 2. Канонизировать запуск suite ВНУТРИ контейнера: `docker exec orchestrator-staging python3 <путь к staging_check.py> --base-url http://localhost:8501` (или эквивалент, где cwd/PYTHONPATH и env — staging-контейнера). Код импортируется из кода контейнера, env уже staging.
|
||||
> 3. **Синхронно** обновить `.openclaw/agents/deployer.md` (способ запуска suite через `docker exec`, НЕ с хоста) и `docs/operations/STAGING_CHECK.md` — иначе host-запуск воспроизведёт баг.
|
||||
> 4. Логику вердикта B6 вынести в чистую функцию `_evaluate_b6(known: set[str]) -> tuple[bool, str]` (TR-2/§9) для unit-теста на оба исхода (AC-2).
|
||||
> 5. НЕ добавлять HTTP-эндпоинт `/projects` и НЕ трогать прод-`src/main.py`. НЕ трогать `src/projects.py`, `.env*`, прочие чеки A/B4/B5/C.
|
||||
>
|
||||
> ### Нюанс топологии (учесть)
|
||||
> `Dockerfile` НЕ копирует `scripts/` в образ → `staging_check.py` доступен в контейнере только через mount `/repos/orchestrator/scripts/...`. Архитектор должен указать в ADR корректный путь запуска внутри контейнера, учитывая этот mount (а не `/app/scripts`).
|
||||
|
||||
---
|
||||
|
||||
## 4-original. Варианты реализации (исходный анализ — справочно)
|
||||
## 4. Варианты реализации (выбор — архитектор, в ADR)
|
||||
|
||||
Бизнес-запрос предлагает три варианта. Анализ перечисляет их с известными плюсами/минусами; решение и обоснование — в `06-adr/`.
|
||||
|
||||
### (а) HTTP-эндпоинт staging-инстанса
|
||||
B6 запрашивает реестр у работающего staging-инстанса по HTTP (как делают A/B4/B5/C).
|
||||
- **Сейчас подходящего эндпоинта НЕТ.** `/health`, `/status`, `/queue` реестр проектов не отдают (`src/main.py`).
|
||||
- Потребуется добавить read-only эндпоинт (напр. `GET /projects`, отдающий `known_plane_project_ids()` или список репо/prefix). Это касается прод-`main.py` → выходит за «не трогать прод-логику», но изменение read-only и низкорисковое — архитектор взвешивает.
|
||||
- Плюс: B6 гарантированно читает реестр именно того процесса, что обслуживает webhooks. Единый HTTP-стиль с остальными чеками.
|
||||
|
||||
### (б) Subprocess в окружении staging-контейнера
|
||||
B6 выполняет `docker exec orchestrator-staging python3 -c "from src.projects import known_plane_project_ids; ..."` и парсит stdout.
|
||||
- Плюс: не трогает прод-`main.py`; читает env контейнера напрямую.
|
||||
- Минус: требует доступности docker-CLI и имени контейнера из среды запуска suite; усложняет запуск «изнутри контейнера»; есть нюансы экранирования (см. `docs/history/LESSONS_2026-06-05.md`).
|
||||
|
||||
### (в) Запуск suite внутри контейнера + чтение собственного process-env
|
||||
Канонизировать запуск `staging_check.py` ВНУТРИ `orchestrator-staging` (`docker exec orchestrator-staging python3 …`), убрать `sys.path.insert(0, "/repos/orchestrator")`, импортировать `src.projects` из кода контейнера (его cwd/PYTHONPATH), env уже staging.
|
||||
- Плюс: минимально инвазивно, не трогает прод-логику и `src.projects`; согласуется с «рекомендуемым способом запуска» в `STAGING_CHECK.md §Способы запуска.1`.
|
||||
- Условие: деплоер должен запускать suite через `docker exec` (а не с хоста). Нужно синхронно обновить `.openclaw/agents/deployer.md` и `STAGING_CHECK.md`, иначе host-запуск воспроизведёт баг.
|
||||
- Нюанс: внутри контейнера код лежит в `/app` (Dockerfile `COPY`), а `/repos/orchestrator` — отдельный mount; импорт должен резолвиться из кода, чьим env реально живёт инстанс.
|
||||
|
||||
## 5. Изменения API
|
||||
|
||||
- Варианты (б) и (в): **нет** изменений API.
|
||||
- Вариант (а): новый read-only эндпоинт (напр. `GET /projects`) — точная схема ответа определяется архитектором. Если выбран — задокументировать в `docs/architecture/README.md` (таблица API) и `CHANGELOG.md`.
|
||||
|
||||
## 6. Изменения схемы БД
|
||||
Нет.
|
||||
|
||||
## 7. Требования к новым QG checks
|
||||
Нет новых QG. Поведение `check_staging_status` (ADR-0003) не меняется — меняется только достоверность одного из чеков suite, чей агрегат пишется в `15-staging-log.md`.
|
||||
|
||||
## 8. Артефакты pipeline, создаваемые/обновляемые
|
||||
- Код: `scripts/staging_check.py` (B6), новый тест в `tests/`.
|
||||
- Док: `docs/operations/STAGING_CHECK.md`; при выборе варианта (а) — `docs/architecture/README.md` (API) и `CHANGELOG.md`; при выборе (в) — `.openclaw/agents/deployer.md` (способ запуска) и `STAGING_CHECK.md`.
|
||||
- ADR: `docs/work-items/ORCH-048/06-adr/ADR-001-*.md` — обоснование выбранного варианта.
|
||||
|
||||
## 9. Тестируемость
|
||||
- Логика «PASS/FAIL по набору known id» B6 должна быть выделена в чистую, юнит-тестируемую функцию (напр. `_evaluate_b6(known: set[str]) -> tuple[bool, str]`), чтобы тест проверял оба исхода без поднятия staging-инстанса/docker. План — `04-test-plan.yaml`.
|
||||
|
||||
## 10. Definition of Done
|
||||
- BR-1…BR-5 (01-brd) выполнены.
|
||||
- staging-прогон → B6 PASS; `pytest tests/ -q` зелёный.
|
||||
- Док и (при необходимости) ADR обновлены в том же PR.
|
||||
67
docs/work-items/ORCH-048/03-acceptance-criteria.md
Normal file
67
docs/work-items/ORCH-048/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 03 — Критерии приёмки (Acceptance Criteria)
|
||||
|
||||
**Work Item:** ORCH-048
|
||||
**Title:** staging B6 check reads registry from host worktree, not staging container
|
||||
**Stage:** analysis
|
||||
**Author:** analyst
|
||||
**Date:** 2026-06-06
|
||||
|
||||
Каждый критерий формулирует чёткое условие PASS/FAIL. Источник — бизнес-запрос ORCH-048 (AC-1…AC-4) + BRD.
|
||||
|
||||
---
|
||||
|
||||
## AC-1 — B6 PASS на staging, читая реестр из staging-окружения
|
||||
|
||||
**Условие PASS:**
|
||||
- При staging-прогоне `scripts/staging_check.py` (канонический способ запуска, выбранный архитектором) чек **B6** выдаёт `✓ PASS` c detail `sandbox=YES, prod-ET=NO(good), prod-ORCH=NO(good)`.
|
||||
- Набор known id, по которому судит B6, получен из окружения работающего staging-инстанса (HTTP-эндпоинт / docker-окружение контейнера / собственный process-env при запуске внутри контейнера), **не** из локального импорта `src.projects` в произвольном process-env с host-path хаком `/repos/orchestrator`.
|
||||
|
||||
**FAIL, если:** B6 даёт ложный FAIL (`prod-ET=YES(BAD!)` / `prod-ORCH=YES(BAD!)`) при фактически исправной изоляции; либо реестр в B6 по-прежнему строится локальным импортом, зависящим от env процесса-запускателя.
|
||||
|
||||
## AC-2 — B6 ловит РЕАЛЬНОЕ нарушение изоляции (оба исхода покрыты тестом)
|
||||
|
||||
**Условие PASS:**
|
||||
- Существует unit-тест, проверяющий логику вердикта B6 на **двух** входах:
|
||||
1. «чистый» staging-реестр (`known = {SANDBOX}`) → B6 вердикт **PASS**;
|
||||
2. «загрязнённый» реестр (например `known = {SANDBOX, PROD_ET}` и/или `{SANDBOX, PROD_ORCH}`) → B6 вердикт **FAIL**.
|
||||
- Тест не требует поднятия живого staging-инстанса/docker (логика вердикта изолирована и тестируема, см. 02-trz §9).
|
||||
|
||||
**FAIL, если:** покрыт только один исход; либо B6 даёт PASS при наличии прод-проекта в реестре (потеря защитной функции).
|
||||
|
||||
## AC-3 — Остальные staging-чеки не сломаны; src/projects.py и .env не тронуты
|
||||
|
||||
**Условие PASS:**
|
||||
- Блоки A1–A3, B4, B5 и блок C (E2E) в `scripts/staging_check.py` функционально не изменены (формат вывода и логика прежние).
|
||||
- `git diff` work item НЕ содержит изменений в `src/projects.py`, `.env`, `.env.staging`, `.env.example`.
|
||||
- Прод-логика оркестратора не затронута. Исключение допускается только если архитектор в ADR выбрал вариант (а) и добавил read-only эндпоинт — тогда изменение ограничено добавлением этого эндпоинта, прод-поведение существующих роутов неизменно.
|
||||
|
||||
**FAIL, если:** изменён `src/projects.py` или любой `.env*`; либо затронута/сломана логика прочих чеков.
|
||||
|
||||
## AC-4 — Существующие unit-тесты зелёные
|
||||
|
||||
**Условие PASS:**
|
||||
- `python -m pytest tests/ -q` завершается с кодом 0; все ранее зелёные тесты остаются зелёными; новый тест B6 (AC-2) проходит.
|
||||
|
||||
**FAIL, если:** любой тест падает.
|
||||
|
||||
## AC-5 — Документация обновлена в том же PR (golden source)
|
||||
|
||||
**Условие PASS:**
|
||||
- `docs/operations/STAGING_CHECK.md` отражает исправленную механику B6 и канонический способ запуска suite.
|
||||
- При выборе варианта (а): обновлены таблица API в `docs/architecture/README.md` и `CHANGELOG.md`.
|
||||
- При выборе варианта (в): обновлены `.openclaw/agents/deployer.md` (запуск через `docker exec`) и `STAGING_CHECK.md`.
|
||||
- Заведён ADR `docs/work-items/ORCH-048/06-adr/ADR-001-*.md` с обоснованием выбранного варианта.
|
||||
|
||||
**FAIL, если:** код изменён, а соответствующая док/ADR не обновлены.
|
||||
|
||||
---
|
||||
|
||||
## Сводная проверка (как мерить приёмку)
|
||||
|
||||
| AC | Команда / действие | Ожидаемый результат |
|
||||
|----|--------------------|---------------------|
|
||||
| AC-1 | staging-прогон suite (выбранным способом) | `B6 … ✓ PASS [sandbox=YES, prod-ET=NO(good), prod-ORCH=NO(good)]` |
|
||||
| AC-2 | `pytest tests/test_staging_check_b6.py -q` | оба кейса (clean→PASS, polluted→FAIL) зелёные |
|
||||
| AC-3 | `git diff --name-only` по ветке | нет `src/projects.py`, нет `.env*`; чеки A/B4/B5/C не изменены по сути |
|
||||
| AC-4 | `python -m pytest tests/ -q` | exit 0, все PASS |
|
||||
| AC-5 | ревью diff документации | STAGING_CHECK.md + ADR-001 присутствуют и согласованы с кодом |
|
||||
97
docs/work-items/ORCH-048/04-test-plan.yaml
Normal file
97
docs/work-items/ORCH-048/04-test-plan.yaml
Normal file
@@ -0,0 +1,97 @@
|
||||
work_item: ORCH-048
|
||||
title: staging B6 check reads registry from host worktree, not staging container
|
||||
stage: analysis
|
||||
notes: >
|
||||
B6 в staging_check.py должен оценивать реестр окружения работающего staging-инстанса.
|
||||
Для тестируемости логика вердикта B6 выделяется в чистую функцию (напр.
|
||||
_evaluate_b6(known: set[str]) -> tuple[bool, str]); тесты бьют именно её и не
|
||||
поднимают живой staging-инстанс/docker. Идентификаторы — те же константы из скрипта:
|
||||
SANDBOX_PROJECT_ID=8c5a3025-4f9d-4190-b79f-fa06276bb27e,
|
||||
PROD_ET_PROJECT_ID=7a79f0a9-5278-49cd-9007-9a338f238f9c,
|
||||
PROD_ORCH_PROJECT_ID=8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a.
|
||||
|
||||
tests:
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: >
|
||||
B6-вердикт PASS при чистом staging-реестре: known={SANDBOX} ->
|
||||
passed=True, detail содержит sandbox=YES, prod-ET=NO, prod-ORCH=NO. (AC-1, AC-2)
|
||||
module: tests/test_staging_check_b6.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: >
|
||||
B6-вердикт FAIL при попадании прод-ET в реестр: known={SANDBOX, PROD_ET} ->
|
||||
passed=False, detail помечает prod-ET как нарушение. (AC-2)
|
||||
module: tests/test_staging_check_b6.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: >
|
||||
B6-вердикт FAIL при попадании прод-ORCH в реестр: known={SANDBOX, PROD_ORCH} ->
|
||||
passed=False, detail помечает prod-ORCH как нарушение. (AC-2)
|
||||
module: tests/test_staging_check_b6.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: >
|
||||
B6-вердикт FAIL при отсутствии sandbox в реестре: known=set() (пусто) ->
|
||||
passed=False (sandbox absent), детерминированно, без исключения. (AC-2, TR-4)
|
||||
module: tests/test_staging_check_b6.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: >
|
||||
B6-вердикт FAIL при загрязнении и ET, и ORCH одновременно:
|
||||
known={SANDBOX, PROD_ET, PROD_ORCH} -> passed=False. (AC-2)
|
||||
module: tests/test_staging_check_b6.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: >
|
||||
Источник реестра в B6 больше не зависит от host-path хака
|
||||
sys.path.insert(0,"/repos/orchestrator"): проверить (статически/через структуру
|
||||
кода или мок источника), что построение known не делается локальным импортом
|
||||
src.projects из произвольного process-env. (AC-1, TR-6)
|
||||
module: tests/test_staging_check_b6.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: >
|
||||
Деградация источника реестра (HTTP-ошибка / недоступный контейнер / битый ответ)
|
||||
-> B6 даёт детерминированный FAIL с понятным detail, а не ложный PASS и не
|
||||
необработанное исключение. (TR-4)
|
||||
module: tests/test_staging_check_b6.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: >
|
||||
Регрессия реестра: существующие тесты src/projects.py остаются зелёными,
|
||||
подтверждая, что src/projects.py не изменён. (AC-3, AC-4)
|
||||
module: tests/test_projects.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-09
|
||||
type: integration
|
||||
description: >
|
||||
Полный прогон pytest без падений после правок:
|
||||
`python -m pytest tests/ -q` -> exit 0. (AC-4)
|
||||
module: tests/
|
||||
expected: PASS
|
||||
|
||||
- id: TC-10
|
||||
type: integration
|
||||
description: >
|
||||
Живой staging-прогон (ручной, вне CI): запустить scripts/staging_check.py
|
||||
выбранным архитектором способом против orchestrator-staging (8501) ->
|
||||
B6 == PASS (sandbox=YES, prod-ET=NO, prod-ORCH=NO); блоки A/B4/B5/C не сломаны.
|
||||
(AC-1, AC-3) Выполняется деплоером на стадии deploy-staging.
|
||||
module: scripts/staging_check.py
|
||||
expected: PASS
|
||||
@@ -0,0 +1,139 @@
|
||||
# ADR-001: B6 читает реестр через запуск suite ВНУТРИ staging-контейнера
|
||||
|
||||
## Статус
|
||||
Accepted
|
||||
|
||||
- **Задача:** ORCH-048
|
||||
- **Дата:** 2026-06-06
|
||||
- **Автор:** architect
|
||||
- **Решение варианта:** принято Владельцем проекта (Слава, 06.06) — вариант **(в)**. Архитектор фиксирует и обосновывает.
|
||||
|
||||
## Контекст
|
||||
|
||||
Чек **B6 «Registry: sandbox present, prod ET/ORCH absent»** в `scripts/staging_check.py`
|
||||
(блок B, ~строки 263–284) — страховка изоляции staging: подтверждает, что в реестре
|
||||
проектов работающего staging-инстанса есть только sandbox-проект и НЕТ боевых
|
||||
(enduro-trails / orchestrator).
|
||||
|
||||
B6 даёт **ложный FAIL** (`prod-ET=YES(BAD!)`, `prod-ORCH=YES(BAD!)`), хотя изоляция
|
||||
реестра в staging исправна. Root cause (подтверждён прямым запуском, 06.06):
|
||||
|
||||
```python
|
||||
sys.path.insert(0, "/repos/orchestrator") # host-worktree path
|
||||
import importlib
|
||||
if "src.projects" in sys.modules:
|
||||
importlib.reload(sys.modules["src.projects"]) # перечитывает env ТЕКУЩЕГО процесса
|
||||
from src.projects import known_plane_project_ids
|
||||
known = known_plane_project_ids()
|
||||
```
|
||||
|
||||
B6 — единственный чек, который не ходит к инстансу по HTTP, а импортирует Python-код
|
||||
локально и строит реестр из `ORCH_PROJECTS_JSON` **process-env того процесса, в котором
|
||||
исполняется скрипт**. Деплоер фактически запускает suite **с хоста**
|
||||
(`python3 scripts/staging_check.py --base-url http://localhost:8501`), где
|
||||
`ORCH_PROJECTS_JSON` не задан → `src.projects` грузит встроенный `_DEFAULT_PROJECTS`
|
||||
(ET + ORCH) → ложный FAIL. B6 проверяет реестр НЕ того окружения, реестр которого
|
||||
реально использует staging-инстанс.
|
||||
|
||||
### Топология (ключевой факт для решения)
|
||||
|
||||
- Контейнер `orchestrator-staging`: `WORKDIR /app`, `ENV PYTHONPATH=/app`; код приложения
|
||||
**скопирован** в образ (`Dockerfile: COPY src/ ./src/`) → живёт в `/app/src/`.
|
||||
- `.env.staging` (env_file контейнера) задаёт `ORCH_PROJECTS_JSON` = только sandbox.
|
||||
- `Dockerfile` **НЕ копирует** `scripts/` в образ. Скрипт доступен в контейнере только
|
||||
через bind-mount `/home/slin/repos:/repos` → `/repos/orchestrator/scripts/staging_check.py`.
|
||||
|
||||
Из этого следует: при запуске `docker exec orchestrator-staging python3
|
||||
/repos/orchestrator/scripts/staging_check.py` интерпретатор добавляет в `sys.path[0]`
|
||||
каталог скрипта (`/repos/orchestrator/scripts`), а `import src.projects` резолвится через
|
||||
`PYTHONPATH=/app` → `/app/src/projects.py` (собственный код контейнера) с env из
|
||||
`.env.staging`. Это ровно реестр работающего staging-инстанса — без HTTP, без host-path хака.
|
||||
|
||||
## Решение
|
||||
|
||||
Принят **вариант (в): канонизировать запуск suite ВНУТРИ `orchestrator-staging` и читать
|
||||
собственный process-env контейнера.**
|
||||
|
||||
Архитектурно фиксируется (детальная реализация — стадия development):
|
||||
|
||||
1. **Убрать из B6 host-path хак:** удалить `sys.path.insert(0, "/repos/orchestrator")` и
|
||||
`importlib.reload(sys.modules["src.projects"])`. Импорт `from src.projects import
|
||||
known_plane_project_ids` остаётся, но резолвится из кода контейнера (`/app/src` через
|
||||
`PYTHONPATH=/app`), env которого — staging (`.env.staging`).
|
||||
|
||||
2. **Канонизировать запуск suite внутри контейнера** (а не с хоста):
|
||||
```bash
|
||||
docker exec orchestrator-staging \
|
||||
python3 /repos/orchestrator/scripts/staging_check.py \
|
||||
--base-url http://localhost:8501 --mode stub
|
||||
```
|
||||
`--base-url http://localhost:8501` корректен изнутри контейнера: сеть `network_mode: host`.
|
||||
Путь к скрипту — `/repos/orchestrator/scripts/...` (mount), а НЕ `/app/scripts` (в образе
|
||||
scripts отсутствует).
|
||||
|
||||
3. **Синхронно обновить документацию запуска** (этот же PR), иначе host-запуск воспроизведёт
|
||||
баг:
|
||||
- `.openclaw/agents/deployer.md` — команда стадии `deploy-staging` через `docker exec`.
|
||||
- `docs/operations/STAGING_CHECK.md` — канонический способ запуска и описание механики B6.
|
||||
|
||||
4. **Логику вердикта B6 вынести в чистую функцию** `_evaluate_b6(known: set[str]) ->
|
||||
tuple[bool, str]`, инвариант (TR-2): `passed ⟺ SANDBOX ∈ known ∧ PROD_ET ∉ known ∧
|
||||
PROD_ORCH ∉ known`; `detail` сохраняет формат `sandbox=…, prod-ET=…, prod-ORCH=…` (TR-3).
|
||||
Функция юнит-тестируема без поднятия инстанса/docker (TC-01…TC-07).
|
||||
|
||||
5. **Детерминированная деградация (TR-4):** при недоступности источника реестра (ошибка
|
||||
импорта/построения `known`) B6 даёт FAIL с понятным detail, без необработанного исключения
|
||||
и без ложного PASS.
|
||||
|
||||
### Границы (scope guards — обязательны)
|
||||
|
||||
- **НЕ** добавлять HTTP-эндпоинт `GET /projects`; **НЕ** трогать прод-`src/main.py`,
|
||||
`src/webhooks/*`, `src/stage_engine.py`, `src/qg/*`.
|
||||
- **НЕ** изменять `src/projects.py`, `.env`, `.env.staging`, `.env.example`.
|
||||
- **НЕ** менять блоки A1–A3, B4, B5 и блок C (E2E): формат вывода и логика прежние.
|
||||
- Реестр QG и стадий не меняется; ADR-0003 (`check_staging_status`) в силе — меняется только
|
||||
достоверность одного чека внутри suite, чей агрегат пишется в `15-staging-log.md`.
|
||||
|
||||
## Альтернативы (отклонены)
|
||||
|
||||
### (а) HTTP-эндпоинт `GET /projects` работающего staging-инстанса — ОТКЛОНЁН
|
||||
Порождает «курицу-яйцо»: B6 ходит на эндпоинт **работающего** инстанса, а эндпоинт запечён
|
||||
в Docker-образ → на первом прогоне его в живом инстансе ещё нет (404) → ложный FAIL → откат.
|
||||
Требует ручного bootstrap-деплоя. Это ровно тот класс поломки автономности, который мы
|
||||
устраняем. Подтверждено на проде 06.06: `GET /projects` на 8501 → 404 → deploy-staging FAILED.
|
||||
(Предыдущая итерация архитектора выбрала (а); решение отклонено Владельцем, код и ADR(а)
|
||||
удалены, ветка откатана к analyst-артефактам.)
|
||||
|
||||
### (б) `docker exec` subprocess + парсинг stdout — ОТКЛОНЁН
|
||||
`docker exec orchestrator-staging python3 -c "..."` из процесса suite. Хрупкое экранирование
|
||||
(`docs/history/LESSONS_2026-06-05.md`), зависимость от наличия docker-CLI и имени контейнера
|
||||
в среде запуска, усложняет запуск «изнутри контейнера».
|
||||
|
||||
### (в) Запуск suite внутри контейнера + собственный process-env — ВЫБРАН
|
||||
B6 не зависит от того, что отдаёт инстанс по HTTP; `staging_check.py` берётся из mount (свежий
|
||||
код сразу, без ребилда образа); реестр читается из env самого `orchestrator-staging`. Курицы-яйца
|
||||
нет ни на первом прогоне, ни в будущем. Минимально инвазивно, прод-логика и `src/projects.py` не
|
||||
тронуты. Согласуется с «рекомендуемым способом запуска» (`STAGING_CHECK.md §Способы запуска.1`).
|
||||
|
||||
## Последствия
|
||||
|
||||
**Плюсы**
|
||||
- B6 достоверно отражает реестр работающего staging-инстанса; ложные FAIL/откаты устранены.
|
||||
- Автономность self-hosting не ломается: нет bootstrap-зависимости от запечённого в образ кода.
|
||||
- Свежий `staging_check.py` подхватывается из mount без ребилда образа.
|
||||
- Защитная функция B6 сохранена и покрыта юнит-тестами на оба исхода (PASS/FAIL).
|
||||
|
||||
**Минусы / ограничения**
|
||||
- Запуск suite **обязан** идти через `docker exec` внутри `orchestrator-staging`. Запуск с
|
||||
хоста воспроизведёт исходный баг (host-env без `ORCH_PROJECTS_JSON`). Это закреплено в
|
||||
`deployer.md` и `STAGING_CHECK.md`; способ «с хоста» остаётся возможен, только если env
|
||||
хоста корректно повторяет staging (не рекомендуется, помечено).
|
||||
- Деплоер должен иметь доступ к docker-CLI/сокету (есть: `/var/run/docker.sock` смонтирован в
|
||||
контейнер оркестратора, у которого deployer-агент исполняется; `deployer.md` tools: Bash docker).
|
||||
|
||||
## Связи
|
||||
- ADR-0003 (`docs/architecture/adr/adr-0003-staging-gate.md`) — staging-гейт, который этот чек
|
||||
обслуживает.
|
||||
- ORCH-6 / `src/projects.py` — реестр проектов (источник `known_plane_project_ids()`),
|
||||
НЕ изменяется.
|
||||
- `docs/history/LESSONS_2026-06-05.md` — обоснование отказа от варианта (б).
|
||||
69
docs/work-items/ORCH-048/12-review.md
Normal file
69
docs/work-items/ORCH-048/12-review.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-048
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-048
|
||||
|
||||
## Summary
|
||||
|
||||
PR чинит ложный FAIL чека **B6** в `scripts/staging_check.py`: реестр проектов теперь
|
||||
читается из окружения работающего staging-инстанса (вариант «в», выбранный Владельцем и
|
||||
зафиксированный в ADR-001), host-path хак `sys.path.insert(0, "/repos/orchestrator")` +
|
||||
`importlib.reload` удалён. Реализация соответствует ТЗ, ADR-001 и всем критериям приёмки.
|
||||
Документация обновлена синхронно. `pytest tests/ -q` — **470 passed**.
|
||||
|
||||
Соответствие осям проверки:
|
||||
|
||||
- **ТЗ (02-trz):** TR-1…TR-6 выполнены. TR-1/TR-6 — реестр строится из process-env
|
||||
инстанса, host-path хак удалён. TR-2 — инвариант `passed ⟺ SANDBOX ∈ known ∧ PROD_ET ∉
|
||||
known ∧ PROD_ORCH ∉ known` в `_evaluate_b6`. TR-3 — формат detail сохранён. TR-4 —
|
||||
детерминированный FAIL при недоступности источника (`_run_b6` ловит `Exception`, нет
|
||||
ложного PASS, нет необработанного исключения). TR-5 — stdlib. §9 — логика вердикта
|
||||
вынесена в чистую `_evaluate_b6` для unit-теста.
|
||||
- **ADR-001:** реализация дословно следует пунктам 1–5 решения и scope-guards.
|
||||
HTTP-эндпоинт не добавлен, прод-`src/main.py` не тронут.
|
||||
- **AC-1…AC-5:** AC-1 — механика читает реестр инстанса; AC-2 — оба исхода покрыты
|
||||
(TC-01 clean→PASS, TC-02/03/05 polluted→FAIL); AC-3 — `git diff` не содержит
|
||||
`src/projects.py`/`.env*`, блоки A1–A3/B4/B5/C не тронуты; AC-4 — 470 passed; AC-5 —
|
||||
STAGING_CHECK.md, deployer.md, CHANGELOG, ADR-001 обновлены в этом же PR.
|
||||
- **Качество кода:** чистые функции, докстринги на всех новых функциях, defensive-обработка,
|
||||
`sys` остаётся используемым (`sys.exit`), без мёртвых импортов. Тесты содержательные
|
||||
(7 TC + happy-path wiring + статическая проверка отсутствия хака).
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- нет
|
||||
|
||||
### P1 — Must fix
|
||||
- нет
|
||||
|
||||
### P2 — Should fix
|
||||
- нет
|
||||
|
||||
### P3 — Nice-to-have
|
||||
- [ ] `test_tc06_no_host_path_hack_in_source` и `test_tc06_registry_loader_uses_src_projects`
|
||||
носят одинаковый префикс `tc06` — формально это два разных кейса; имена можно было бы
|
||||
развести для читаемости отчёта pytest. Косметика, на приёмку не влияет.
|
||||
|
||||
## Документация
|
||||
|
||||
Полностью обновлена в том же PR (golden source соблюдён):
|
||||
|
||||
- `docs/operations/STAGING_CHECK.md` — канонический способ запуска (способ 1 через
|
||||
`docker exec`), способ «с хоста» помечен как невалидный/воспроизводящий баг, добавлена
|
||||
секция «Механика чека B6».
|
||||
- `.openclaw/agents/deployer.md` — команда стадии `deploy-staging` переведена на
|
||||
`docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py …`
|
||||
с пояснением, почему host-запуск ломает B6.
|
||||
- `CHANGELOG.md` — запись в разделе Fixed с полным описанием root cause и решения.
|
||||
- ADR `docs/work-items/ORCH-048/06-adr/ADR-001-b6-registry-via-in-container-run.md` —
|
||||
обоснование варианта (в), отклонённые (а)/(б), scope-guards.
|
||||
|
||||
`docs/architecture/README.md` обновлять не требовалось: API, реестр стадий и `QG_CHECKS`
|
||||
не менялись (изменение касается только достоверности одного чека внутри suite).
|
||||
|
||||
**Вердикт: APPROVED** — P0/P1 отсутствуют.
|
||||
79
docs/work-items/ORCH-048/13-test-report.md
Normal file
79
docs/work-items/ORCH-048/13-test-report.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-048
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-048
|
||||
|
||||
**Title:** staging B6 check reads registry from host worktree, not staging container
|
||||
**Stage:** testing
|
||||
**Branch:** feature/ORCH-048-staging-b6-check-reads-registr
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Дата: 2026-06-06T07:06Z
|
||||
- Prod API (8500): `/health` 200 ok, `/status` 200 (ORCH-048 в stage=testing), `/queue` 200 (breaker closed, preflight ok)
|
||||
|
||||
## Результаты
|
||||
|
||||
| TC ID | Тип | Описание | Результат |
|
||||
|-------|-----|----------|-----------|
|
||||
| TC-01 | unit | `known={SANDBOX}` → B6 PASS, detail sandbox=YES/prod-ET=NO/prod-ORCH=NO | PASS |
|
||||
| TC-02 | unit | `known={SANDBOX,PROD_ET}` → B6 FAIL, prod-ET помечен нарушением | PASS |
|
||||
| TC-03 | unit | `known={SANDBOX,PROD_ORCH}` → B6 FAIL, prod-ORCH помечен нарушением | PASS |
|
||||
| TC-04 | unit | `known=set()` (нет sandbox) → детерминированный FAIL без исключения | PASS |
|
||||
| TC-05 | unit | `known={SANDBOX,PROD_ET,PROD_ORCH}` → B6 FAIL | PASS |
|
||||
| TC-06 | unit | Нет host-path хака `/repos/orchestrator`; реестр строится не локальным импортом в произвольном process-env | PASS |
|
||||
| TC-07 | unit | Деградация источника реестра → детерминированный FAIL с понятным detail (не ложный PASS, не необработанное исключение) | PASS |
|
||||
| TC-08 | unit | Регрессия `src/projects.py` (16 тестов) зелёные — реестр не изменён | PASS |
|
||||
| TC-09 | integration | `python -m pytest tests/ -q` → exit 0 | PASS |
|
||||
| TC-10 | integration | Живой staging-прогон B6 на 8501 | DEFERRED — выполняется деплоером на стадии deploy-staging (см. 04-test-plan TC-10) |
|
||||
|
||||
Доп. покрытие: `test_run_b6_records_pass_for_clean_registry` (happy-path wiring `_run_b6`).
|
||||
|
||||
## Покрытие критериев приёмки
|
||||
|
||||
| AC | Подтверждение | Статус |
|
||||
|----|---------------|--------|
|
||||
| AC-1 | B6 PASS на чистом реестре (TC-01), источник — окружение инстанса, host-path хак удалён (TC-06) | PASS |
|
||||
| AC-2 | Оба исхода покрыты: clean→PASS (TC-01), polluted→FAIL (TC-02/03/05), без sandbox→FAIL (TC-04) | PASS |
|
||||
| AC-3 | `git diff origin/main...HEAD` НЕ содержит `src/projects.py` / `.env*`; блоки A/B4/B5/C не тронуты | PASS |
|
||||
| AC-4 | `pytest tests/ -q` → exit 0, 470 passed | PASS |
|
||||
| AC-5 | STAGING_CHECK.md, deployer.md, CHANGELOG.md, ADR-001 обновлены в том же PR (подтверждено review) | PASS |
|
||||
|
||||
## Проверка scope (AC-3)
|
||||
Изменённые файлы ветки vs origin/main:
|
||||
```
|
||||
.openclaw/agents/deployer.md
|
||||
CHANGELOG.md
|
||||
docs/operations/STAGING_CHECK.md
|
||||
docs/work-items/ORCH-048/* (артефакты задачи)
|
||||
scripts/staging_check.py
|
||||
tests/test_staging_check_b6.py
|
||||
```
|
||||
Forbidden-path check: OK — `src/projects.py` и `.env*` НЕ затронуты.
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
Полный прогон:
|
||||
```
|
||||
470 passed, 1 warning in 9.07s
|
||||
pytest exit code: 0
|
||||
```
|
||||
(warning — PydanticDeprecatedSince20 в `src/config.py`, предсуществующий, к ORCH-048 не относится.)
|
||||
|
||||
B6 suite:
|
||||
```
|
||||
tests/test_staging_check_b6.py ......... [9 passed]
|
||||
9 passed, 1 warning in 0.29s
|
||||
```
|
||||
|
||||
projects-регрессия (TC-08):
|
||||
```
|
||||
16 passed, 1 warning in 0.31s
|
||||
```
|
||||
|
||||
## Итог
|
||||
**PASS** — все unit/integration тесты зелёные (470 passed, exit 0), smoke API prod исправен, scope-инвариант AC-3 соблюдён. Живой staging-прогон B6 (TC-10) выполняется деплоером на стадии deploy-staging.
|
||||
42
docs/work-items/ORCH-048/14-deploy-log.md
Normal file
42
docs/work-items/ORCH-048/14-deploy-log.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
timestamp: 2026-06-06T07:12:00Z
|
||||
work_item: ORCH-048
|
||||
target: orchestrator (prod, self-hosting)
|
||||
restart_required: false
|
||||
rebuild_required: false
|
||||
staging_gate: SUCCESS
|
||||
---
|
||||
|
||||
# Production Deploy Log — ORCH-048
|
||||
|
||||
Production rollout of ORCH-048 (`fix(staging_check): B6 reads registry from running staging instance env`) is **complete on merge** — no prod container rebuild or restart was required, and none was performed.
|
||||
|
||||
## Precondition: staging gate
|
||||
|
||||
`deploy` is reachable only because the staging gate (`deploy-staging`) passed:
|
||||
|
||||
- `15-staging-log.md` → `staging_status: SUCCESS`, **10/10 checks PASS** (incl. B6 registry-isolation, the fix's target), run inside `orchestrator-staging` (8501).
|
||||
|
||||
## Change scope (why no restart)
|
||||
|
||||
The work item changes touch **only bind-mounted / non-runtime artifacts** — `src/` (the code running in the prod container) and the `Dockerfile` are untouched:
|
||||
|
||||
| File | Kind | How it reaches prod |
|
||||
|------|------|---------------------|
|
||||
| `scripts/staging_check.py` | bind-mounted (`/home/slin/repos` → `/repos`); **not** copied into the image (`scripts/` absent from `/app`) | host `git pull` → live immediately |
|
||||
| `.openclaw/agents/deployer.md` | bind-mounted agent prompt, read at agent launch | host `git pull` → live on next agent run |
|
||||
| `CHANGELOG.md`, `docs/operations/STAGING_CHECK.md` | docs | n/a |
|
||||
| `tests/test_staging_check_b6.py` | test, not deployed | n/a |
|
||||
|
||||
Because nothing in `src/` or the image changed, there is **no container rebuild and no restart** for the shared prod `orchestrator` (8500). Per CLAUDE.md / INFRA.md self-hosting rules, the prod container that serves all projects (enduro-trails + orchestrator) was **not** touched — zero group-risk.
|
||||
|
||||
## Deploy action
|
||||
|
||||
- **Prod container restart/rebuild:** not required, not performed (guardrail: never restart prod `orchestrator` within an ORCH task).
|
||||
- **Real docker/SSH deploy hook** (`scripts/orchestrator-deploy-hook.sh`): not triggered by this agent (not explicitly instructed; reserved for Owner per ORCH-36).
|
||||
- **Effective rollout:** merge of this branch to `main` + routine host `git pull` makes the corrected `staging_check.py` and `deployer.md` live; the prod app process is unaffected.
|
||||
|
||||
## Verdict
|
||||
|
||||
`deploy_status: SUCCESS` — staging gate green, change is bind-mount-only, prod instance untouched, no rollback needed.
|
||||
50
docs/work-items/ORCH-048/15-staging-log.md
Normal file
50
docs/work-items/ORCH-048/15-staging-log.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-06T07:08:59Z
|
||||
base_url: http://localhost:8501
|
||||
work_item: ORCH-048
|
||||
mode: stub
|
||||
result: 10/10 checks PASS
|
||||
---
|
||||
|
||||
# Staging Gate Log — ORCH-048
|
||||
|
||||
Staging test suite completed against the live `orchestrator-staging` instance (port 8501). **All 10/10 checks passed.**
|
||||
|
||||
## Execution context
|
||||
|
||||
- **Where**: inside the `orchestrator-staging` container via Docker Engine API exec (canonical per ORCH-048 / ADR-001; `docker` CLI not present in this agent env, so the bind-mounted socket `/var/run/docker.sock` was used directly).
|
||||
- **Command**: `python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`
|
||||
- **Exit code**: `0`
|
||||
- **Container state**: `orchestrator-staging` running (Up 25 hours).
|
||||
|
||||
Running inside the container is required so the B6 registry-isolation check reads the registry from the running instance's own process-env (`.env.staging` → `ORCH_PROJECTS_JSON` = sandbox-only). This is precisely the behaviour ORCH-048 corrects.
|
||||
|
||||
## 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 (found 5 project(s), sandbox=YES)
|
||||
✓ PASS B5 Gitea: orchestrator-sandbox accessible, push=true
|
||||
✓ PASS B6 Registry: sandbox present, prod ET/ORCH absent [sandbox=YES, prod-ET=NO(good), prod-ORCH=NO(good)]
|
||||
|
||||
[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 sandbox branch, Plane issue, and DB rows
|
||||
|
||||
============================================================
|
||||
RESULT: 10/10 checks PASS
|
||||
============================================================
|
||||
```
|
||||
|
||||
**B6 verdict (the ORCH-048 target check): PASS** — registry read from the running staging instance correctly shows sandbox present and prod ET/ORCH absent, with no false FAIL / spurious rollback.
|
||||
@@ -8,8 +8,14 @@ Checks:
|
||||
Block C — E2E (create task in SANDBOX → trigger pipeline via /webhook/plane
|
||||
→ verify branch + job enqueued → CLEANUP in finally)
|
||||
|
||||
Usage (inside the container or with correct env set):
|
||||
python3 scripts/staging_check.py [--base-url http://localhost:8501] [--mode stub|full-real]
|
||||
Usage — CANONICAL: run INSIDE the orchestrator-staging container (ORCH-048, ADR-001)
|
||||
so B6 reads the registry from the running instance's own env (.env.staging):
|
||||
docker exec orchestrator-staging \
|
||||
python3 /repos/orchestrator/scripts/staging_check.py \
|
||||
--base-url http://localhost:8501 [--mode stub|full-real]
|
||||
|
||||
Running from the host leaves ORCH_PROJECTS_JSON unset → B6 falls back to the
|
||||
default (ET+ORCH) registry → false FAIL. See docs/operations/STAGING_CHECK.md.
|
||||
|
||||
Exit code: 0 = all PASS, non-zero = at least one FAIL.
|
||||
|
||||
@@ -214,6 +220,59 @@ SANDBOX_PROJECT_ID = "8c5a3025-4f9d-4190-b79f-fa06276bb27e"
|
||||
PROD_ET_PROJECT_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
||||
PROD_ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
|
||||
|
||||
B6_LABEL = "B6 Registry: sandbox present, prod ET/ORCH absent"
|
||||
|
||||
|
||||
def _evaluate_b6(known: set[str]) -> tuple[bool, str]:
|
||||
"""Pure verdict logic for the B6 registry-isolation check (ORCH-048).
|
||||
|
||||
PASS ⟺ SANDBOX ∈ known ∧ PROD_ET ∉ known ∧ PROD_ORCH ∉ known (TR-2).
|
||||
``detail`` keeps the human-readable ``sandbox=…, prod-ET=…, prod-ORCH=…``
|
||||
format (TR-3). Isolated from any I/O so both outcomes are unit-testable
|
||||
without a live staging instance or docker (02-trz §9, ADR-001).
|
||||
"""
|
||||
sandbox_present = SANDBOX_PROJECT_ID in known
|
||||
et_absent = PROD_ET_PROJECT_ID not in known
|
||||
orch_absent = PROD_ORCH_PROJECT_ID not in known
|
||||
passed = sandbox_present and et_absent and orch_absent
|
||||
detail = (
|
||||
f"sandbox={'YES' if sandbox_present else 'NO'}, "
|
||||
f"prod-ET={'NO(good)' if et_absent else 'YES(BAD!)'}, "
|
||||
f"prod-ORCH={'NO(good)' if orch_absent else 'YES(BAD!)'}"
|
||||
)
|
||||
return passed, detail
|
||||
|
||||
|
||||
def _known_project_ids_from_registry() -> set[str]:
|
||||
"""Registry of the *running staging instance* — its own process-env (ORCH-048).
|
||||
|
||||
The suite is canonically run INSIDE ``orchestrator-staging`` via
|
||||
``docker exec`` (ADR-001), so ``src.projects`` resolves through the
|
||||
container's ``PYTHONPATH=/app`` to ``/app/src/projects.py`` and reads
|
||||
``ORCH_PROJECTS_JSON`` from ``.env.staging``. This reflects exactly the
|
||||
registry the live instance serves webhooks with — no host-path hack, no HTTP
|
||||
bootstrap dependency.
|
||||
"""
|
||||
from src.projects import known_plane_project_ids
|
||||
return known_plane_project_ids()
|
||||
|
||||
|
||||
def _run_b6(results: Results) -> None:
|
||||
"""Run the B6 registry-isolation check and record its verdict.
|
||||
|
||||
Builds the known-id set from the running instance's registry and applies
|
||||
``_evaluate_b6``. Any failure to obtain the registry yields a deterministic
|
||||
FAIL with a clear detail (TR-4) — never an unhandled exception and never a
|
||||
false PASS.
|
||||
"""
|
||||
try:
|
||||
known = _known_project_ids_from_registry()
|
||||
except Exception as e:
|
||||
results.add(B6_LABEL, False, f"registry source unavailable: {e}")
|
||||
return
|
||||
passed, detail = _evaluate_b6(known)
|
||||
results.add(B6_LABEL, passed, detail)
|
||||
|
||||
|
||||
def block_b(results: Results):
|
||||
print(f"\n{_BOLD}[Block B] ACCESS{_RESET}")
|
||||
@@ -260,28 +319,11 @@ def block_b(results: Results):
|
||||
except Exception as e:
|
||||
results.add("B5 Gitea: orchestrator-sandbox accessible, push=true", False, str(e))
|
||||
|
||||
# B6 — Registry: sandbox in known IDs, prod ET/ORCH NOT in known IDs
|
||||
try:
|
||||
# Import from inside the container (script runs in /repos/orchestrator context)
|
||||
sys.path.insert(0, "/repos/orchestrator")
|
||||
# Force reload to pick up container env
|
||||
import importlib
|
||||
if "src.projects" in sys.modules:
|
||||
importlib.reload(sys.modules["src.projects"])
|
||||
from src.projects import known_plane_project_ids
|
||||
known = known_plane_project_ids()
|
||||
sandbox_present = SANDBOX_PROJECT_ID in known
|
||||
et_absent = PROD_ET_PROJECT_ID not in known
|
||||
orch_absent = PROD_ORCH_PROJECT_ID not in known
|
||||
ok = sandbox_present and et_absent and orch_absent
|
||||
detail = (
|
||||
f"sandbox={'YES' if sandbox_present else 'NO'}, "
|
||||
f"prod-ET={'NO(good)' if et_absent else 'YES(BAD!)'}, "
|
||||
f"prod-ORCH={'NO(good)' if orch_absent else 'YES(BAD!)'}"
|
||||
)
|
||||
results.add("B6 Registry: sandbox present, prod ET/ORCH absent", ok, detail)
|
||||
except Exception as e:
|
||||
results.add("B6 Registry: sandbox present, prod ET/ORCH absent", False, str(e))
|
||||
# B6 — Registry: sandbox in known IDs, prod ET/ORCH NOT in known IDs (ORCH-048).
|
||||
# Reads the registry of the running staging instance from its own process-env
|
||||
# (canonical: docker exec inside orchestrator-staging — ADR-001). No host-path
|
||||
# hack; deterministic FAIL if the registry source is unavailable (TR-4).
|
||||
_run_b6(results)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
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]:
|
||||
|
||||
205
src/review_parse.py
Normal file
205
src/review_parse.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Defensive extractors for reviewer / tester artifact bodies (ORCH-046).
|
||||
|
||||
When a task is rolled back to ``development`` the stage engine builds the
|
||||
``task_desc`` that ends up in the developer agent's ``.task-dev.md``. Historically
|
||||
that text only carried a *link* to the artifact file (12-review.md /
|
||||
13-test-report.md); the developer agent had to go read the file, and the key
|
||||
must-fix points (reviewer P0/P1 findings, tester failure reason) were lost in
|
||||
transit — "испорченный телефон" that burns the retry budget.
|
||||
|
||||
This module extracts the **verbatim** must-fix text so the stage engine can embed
|
||||
it directly in ``task_desc`` (ADR docs/work-items/ORCH-046/06-adr/ADR-001-*).
|
||||
|
||||
Contract — **never raises** (mirrors ``src/frontmatter.py`` and
|
||||
``src/qg/checks.py::_parse_tests_verdict``): any error — missing file, IOError,
|
||||
malformed markdown/YAML, missing section — yields ``""``. The caller then falls
|
||||
back to the previous link-only ``task_desc``. No network calls; disk reads only.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
logger = logging.getLogger("orchestrator.review_parse")
|
||||
|
||||
# Truncation limits (module-level per ТЗ §2.3). The full context always stays in
|
||||
# the artifact file; the embedded text is a focused excerpt.
|
||||
MAX_FINDINGS_CHARS = 2000
|
||||
MAX_FAILURES_CHARS = 2000
|
||||
|
||||
_TRUNCATED_MARKER = "\n…(truncated)"
|
||||
|
||||
# Recognize a `### P0`/`### P1` subsection header by the presence of the P0/P1
|
||||
# token, tolerant to case and the dash/em-dash that follows it.
|
||||
_P01_HEADER_RE = re.compile(r"(?<![A-Za-z0-9])p[01](?![0-9])", re.IGNORECASE)
|
||||
|
||||
|
||||
def _read(path: str) -> str | None:
|
||||
"""Read a file as UTF-8. Never raises; returns None on any OS error."""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
except OSError as e:
|
||||
logger.debug(f"review_parse: cannot open {path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _strip_frontmatter(content: str) -> str:
|
||||
"""Drop a leading ``--- … ---`` YAML frontmatter block, if present."""
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
return parts[2]
|
||||
return content
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
"""Trim ``text`` to ``limit`` chars, appending a truncation marker if cut."""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit].rstrip() + _TRUNCATED_MARKER
|
||||
|
||||
|
||||
def _section_body(md: str, heading_token: str) -> str:
|
||||
"""Return the body lines under the first ``## <…heading_token…>`` heading.
|
||||
|
||||
Capture stops at the next level-2 (``## ``) heading. Matching is
|
||||
case-insensitive substring match on the heading line, so callers pass a token
|
||||
like ``"Вывод pytest"`` or ``"Findings"``. ``### ``-level headers do NOT
|
||||
delimit the section (they start with ``"### "``, not ``"## "``).
|
||||
"""
|
||||
out: list[str] = []
|
||||
capturing = False
|
||||
for line in md.splitlines():
|
||||
if line.startswith("## "):
|
||||
if capturing:
|
||||
break
|
||||
if heading_token.lower() in line.lower():
|
||||
capturing = True
|
||||
continue
|
||||
if capturing:
|
||||
out.append(line)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _is_placeholder_item(text: str) -> bool:
|
||||
"""True for empty or template-placeholder list items (non-substantive).
|
||||
|
||||
The canonical reviewer template seeds each severity with
|
||||
``- [ ] <описание> (если есть)``. Such lines must be ignored so an empty P0/P1
|
||||
subsection does not leak the placeholder into ``task_desc``.
|
||||
"""
|
||||
t = text.strip()
|
||||
if not t:
|
||||
return True
|
||||
if "(если есть)" in t:
|
||||
return True
|
||||
# An item whose entire payload is an angle-bracket placeholder, e.g. "<описание>".
|
||||
if t.startswith("<") and t.endswith(">"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _item_payload(line: str) -> str | None:
|
||||
"""If ``line`` is a markdown list item, return its payload text; else None.
|
||||
|
||||
Handles ``- foo``, ``* foo`` and checkbox forms ``- [ ] foo`` / ``- [x] foo``.
|
||||
"""
|
||||
m = re.match(r"\s*[-*]\s+(?:\[[ xX]?\]\s*)?(.*)$", line)
|
||||
if not m:
|
||||
return None
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _findings_subsections(findings_body: str):
|
||||
"""Yield ``(header_line, body_lines)`` for each ``### `` subsection."""
|
||||
header: str | None = None
|
||||
body: list[str] = []
|
||||
for line in findings_body.splitlines():
|
||||
if line.startswith("### "):
|
||||
if header is not None:
|
||||
yield header, body
|
||||
header = line
|
||||
body = []
|
||||
elif header is not None:
|
||||
body.append(line)
|
||||
if header is not None:
|
||||
yield header, body
|
||||
|
||||
|
||||
def extract_review_findings(path: str) -> str:
|
||||
"""Дословный текст P0/P1 findings из 12-review.md. Never raises; '' при ошибке/пусто.
|
||||
|
||||
Reads the ``## Findings`` section of a reviewer report and returns the verbatim
|
||||
P0 (Blocker) and P1 (Must fix) subsection items, suitable for embedding in a
|
||||
rollback ``task_desc``. P2/P3 are ignored. Empty/placeholder-only subsections
|
||||
are skipped; if no substantive P0/P1 item exists, returns ``""``. The result is
|
||||
truncated to ``MAX_FINDINGS_CHARS``.
|
||||
"""
|
||||
content = _read(path)
|
||||
if content is None:
|
||||
return ""
|
||||
|
||||
try:
|
||||
body = _strip_frontmatter(content)
|
||||
findings_body = _section_body(body, "Findings")
|
||||
if not findings_body.strip():
|
||||
return ""
|
||||
|
||||
blocks: list[str] = []
|
||||
for header, sub_body in _findings_subsections(findings_body):
|
||||
if not _P01_HEADER_RE.search(header):
|
||||
continue
|
||||
kept: list[str] = []
|
||||
for line in sub_body:
|
||||
payload = _item_payload(line)
|
||||
if payload is None:
|
||||
continue
|
||||
if _is_placeholder_item(payload):
|
||||
continue
|
||||
kept.append(line.rstrip())
|
||||
if kept:
|
||||
blocks.append("\n".join([header.rstrip(), *kept]))
|
||||
|
||||
if not blocks:
|
||||
return ""
|
||||
return _truncate("\n\n".join(blocks), MAX_FINDINGS_CHARS)
|
||||
except Exception as e: # defensive: never raise out of the extractor
|
||||
logger.debug(f"review_parse: extract_review_findings failed for {path}: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def extract_test_failures(path: str) -> str:
|
||||
"""Релевантный фрагмент тела 13-test-report.md (причина FAIL). Never raises; '' при ошибке/пусто.
|
||||
|
||||
Picks the first non-empty source, in priority order:
|
||||
1. ``## Вывод pytest`` — the pytest run output (shows failing tests);
|
||||
2. rows of the ``## Результаты`` table that contain ``FAIL``;
|
||||
3. ``## Итог`` — the verdict summary.
|
||||
The result is truncated to ``MAX_FAILURES_CHARS``. The gate ``reason`` is added
|
||||
by the caller; this returns the report-body excerpt on top of it.
|
||||
"""
|
||||
content = _read(path)
|
||||
if content is None:
|
||||
return ""
|
||||
|
||||
try:
|
||||
# 1. pytest output.
|
||||
pytest_out = _section_body(content, "Вывод pytest").strip()
|
||||
if pytest_out:
|
||||
return _truncate(pytest_out, MAX_FAILURES_CHARS)
|
||||
|
||||
# 2. FAIL rows from the results table.
|
||||
results = _section_body(content, "Результаты")
|
||||
fail_rows = [ln.rstrip() for ln in results.splitlines() if "FAIL" in ln.upper()]
|
||||
if fail_rows:
|
||||
return _truncate("\n".join(fail_rows).strip(), MAX_FAILURES_CHARS)
|
||||
|
||||
# 3. Verdict summary.
|
||||
itog = _section_body(content, "Итог").strip()
|
||||
if itog:
|
||||
return _truncate(itog, MAX_FAILURES_CHARS)
|
||||
|
||||
return ""
|
||||
except Exception as e: # defensive: never raise out of the extractor
|
||||
logger.debug(f"review_parse: extract_test_failures failed for {path}: {e}")
|
||||
return ""
|
||||
@@ -32,6 +32,7 @@ from dataclasses import dataclass, field
|
||||
from .db import get_db, update_task_stage, enqueue_job
|
||||
from .stages import get_next_stage, get_qg_for_stage, get_agent_for_stage
|
||||
from .git_worktree import get_worktree_path
|
||||
from .review_parse import extract_review_findings, extract_test_failures
|
||||
from .qg.checks import QG_CHECKS
|
||||
from .notifications import (
|
||||
notify_stage_change,
|
||||
@@ -416,12 +417,24 @@ def _handle_qg_failure_rollbacks(
|
||||
result.rolled_back_to = "development"
|
||||
retry_count = _developer_retry_count(task_id)
|
||||
if retry_count < MAX_DEVELOPER_RETRIES:
|
||||
task_desc = (
|
||||
# ORCH-046: embed the verbatim P0/P1 findings into task_desc so the
|
||||
# developer agent sees the must-fix points directly (not just a link).
|
||||
# extract_review_findings never raises; "" -> graceful link-only fallback.
|
||||
review_ref = f"docs/work-items/{work_item_id}/12-review.md"
|
||||
review_path = os.path.join(get_worktree_path(repo, branch), review_ref)
|
||||
findings = extract_review_findings(review_path)
|
||||
head = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: development\nNote: REQUEST_CHANGES from reviewer "
|
||||
f"(attempt {retry_count+1}/3). Fix findings in "
|
||||
f"docs/work-items/{work_item_id}/12-review.md"
|
||||
f"(attempt {retry_count+1}/3)."
|
||||
)
|
||||
if findings:
|
||||
task_desc = (
|
||||
f"{head}\nFindings (P0/P1):\n{findings}\n"
|
||||
f"Полный контекст: {review_ref}"
|
||||
)
|
||||
else:
|
||||
task_desc = f"{head} Fix findings in {review_ref}"
|
||||
new_job = enqueue_job("developer", repo, task_desc, task_id=task_id)
|
||||
result.enqueued_agent = "developer"
|
||||
result.enqueued_job_id = new_job
|
||||
@@ -452,11 +465,23 @@ def _handle_qg_failure_rollbacks(
|
||||
)
|
||||
retry_count = _developer_retry_count(task_id)
|
||||
if retry_count < MAX_DEVELOPER_RETRIES:
|
||||
task_desc = (
|
||||
# ORCH-046: embed the gate `reason` plus a verbatim excerpt of the
|
||||
# test-report body (pytest output / FAIL rows / Итог) into task_desc.
|
||||
# extract_test_failures never raises; "" -> graceful reason+link fallback.
|
||||
report_ref = f"docs/work-items/{work_item_id}/13-test-report.md"
|
||||
report_path = os.path.join(get_worktree_path(repo, branch), report_ref)
|
||||
failures = extract_test_failures(report_path)
|
||||
head = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: development\nNote: Tests FAILED. "
|
||||
f"Fix failures described in docs/work-items/{work_item_id}/13-test-report.md"
|
||||
f"Stage: development\nNote: Tests FAILED. Причина: {reason}."
|
||||
)
|
||||
if failures:
|
||||
task_desc = (
|
||||
f"{head}\nДетали:\n{failures}\n"
|
||||
f"Полный контекст: {report_ref}"
|
||||
)
|
||||
else:
|
||||
task_desc = f"{head} Fix failures described in {report_ref}"
|
||||
new_job = enqueue_job("developer", repo, task_desc, task_id=task_id)
|
||||
result.enqueued_agent = "developer"
|
||||
result.enqueued_job_id = new_job
|
||||
|
||||
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)
|
||||
|
||||
237
tests/test_review_parse.py
Normal file
237
tests/test_review_parse.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""Unit tests for src/review_parse (ORCH-046).
|
||||
|
||||
Covers the defensive extractors that pull verbatim must-fix text out of the
|
||||
reviewer / tester artifacts for embedding into the rollback ``task_desc``:
|
||||
|
||||
- extract_review_findings (12-review.md, ## Findings -> P0/P1)
|
||||
- extract_test_failures (13-test-report.md, pytest/FAIL/Итог excerpt)
|
||||
|
||||
Both must NEVER raise (return "" on missing/broken/empty input) and must ignore
|
||||
template placeholders / non-must-fix severities. See 04-test-plan.yaml (TC-01..08).
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from src.review_parse import (
|
||||
extract_review_findings,
|
||||
extract_test_failures,
|
||||
MAX_FINDINGS_CHARS,
|
||||
MAX_FAILURES_CHARS,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def write_file(tmp_path):
|
||||
def _w(name: str, content: str) -> str:
|
||||
p = tmp_path / name
|
||||
p.write_text(content, encoding="utf-8")
|
||||
return str(p)
|
||||
return _w
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_review_findings
|
||||
# ---------------------------------------------------------------------------
|
||||
_REVIEW_WITH_FINDINGS = """---
|
||||
type: review
|
||||
work_item_id: ORCH-046
|
||||
verdict: REQUEST_CHANGES
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-046
|
||||
|
||||
## Summary
|
||||
Несколько проблем.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- [ ] Документация не обновлена при изменении src/review_parse.py
|
||||
|
||||
### P1 — Must fix
|
||||
- [ ] extract_test_failures не обрабатывает пустой отчёт
|
||||
- [ ] Отсутствует docstring у _section_body
|
||||
|
||||
### P2 — Should fix
|
||||
- [ ] Переименовать переменную blocks в more descriptive
|
||||
|
||||
## Документация
|
||||
Требует обновления README.
|
||||
"""
|
||||
|
||||
|
||||
class TestExtractReviewFindings:
|
||||
def test_tc01_returns_verbatim_p0_p1(self, write_file):
|
||||
"""TC-01: P0/P1 findings present -> verbatim text returned (AC-1, AC-5)."""
|
||||
path = write_file("12-review.md", _REVIEW_WITH_FINDINGS)
|
||||
out = extract_review_findings(path)
|
||||
# P0 + P1 verbatim items present.
|
||||
assert "Документация не обновлена при изменении src/review_parse.py" in out
|
||||
assert "extract_test_failures не обрабатывает пустой отчёт" in out
|
||||
assert "Отсутствует docstring у _section_body" in out
|
||||
# Subsection headers preserved.
|
||||
assert "P0" in out and "P1" in out
|
||||
# P2 must NOT leak in.
|
||||
assert "Переименовать переменную" not in out
|
||||
|
||||
def test_tc02_only_p2_p3_returns_empty(self, write_file):
|
||||
"""TC-02: only P2/P3 (no must-fix P0/P1) -> '' (AC-5)."""
|
||||
content = """---
|
||||
verdict: REQUEST_CHANGES
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- [ ] <описание> (если есть)
|
||||
|
||||
### P1 — Must fix
|
||||
- [ ] <описание> (если есть)
|
||||
|
||||
### P2 — Should fix
|
||||
- [ ] Косметика в naming
|
||||
"""
|
||||
path = write_file("12-review.md", content)
|
||||
assert extract_review_findings(path) == ""
|
||||
|
||||
def test_tc03_missing_file_returns_empty(self):
|
||||
"""TC-03: non-existent path -> '' without raising (AC-4)."""
|
||||
missing = os.path.join(tempfile.gettempdir(), "no-such-review-orch046.md")
|
||||
assert extract_review_findings(missing) == ""
|
||||
|
||||
def test_tc04_broken_or_no_findings_section_returns_empty(self, write_file):
|
||||
"""TC-04: empty file / markdown without ## Findings -> '' (AC-4, AC-5)."""
|
||||
# Empty file.
|
||||
assert extract_review_findings(write_file("empty.md", "")) == ""
|
||||
# No Findings section.
|
||||
no_section = "# Review\n\n## Summary\nвсё хорошо\n"
|
||||
assert extract_review_findings(write_file("nofind.md", no_section)) == ""
|
||||
# Broken YAML frontmatter (unterminated) — body parsing still graceful.
|
||||
broken = "---\nverdict: [unclosed\n# Review\nno findings here\n"
|
||||
assert extract_review_findings(write_file("broken.md", broken)) == ""
|
||||
|
||||
def test_tc05_long_findings_truncated(self, write_file):
|
||||
"""TC-05: very long findings truncated to limit with marker (AC-1)."""
|
||||
big_item = "- [ ] " + ("x" * 5000)
|
||||
content = f"## Findings\n\n### P0 — Blocker\n{big_item}\n"
|
||||
path = write_file("12-review.md", content)
|
||||
out = extract_review_findings(path)
|
||||
assert len(out) <= MAX_FINDINGS_CHARS + len("\n…(truncated)")
|
||||
assert "…(truncated)" in out
|
||||
|
||||
def test_case_insensitive_and_dash_tolerant_header(self, write_file):
|
||||
"""P0/P1 recognized regardless of case / dash style."""
|
||||
content = """## Findings
|
||||
|
||||
### p0 - blocker
|
||||
- [ ] Нижний регистр заголовка
|
||||
|
||||
### P1 — Must fix
|
||||
- [ ] Em-dash заголовок
|
||||
"""
|
||||
out = extract_review_findings(write_file("12-review.md", content))
|
||||
assert "Нижний регистр заголовка" in out
|
||||
assert "Em-dash заголовок" in out
|
||||
|
||||
def test_never_raises_on_directory_path(self, tmp_path):
|
||||
"""Passing a directory path must not raise -> ''."""
|
||||
assert extract_review_findings(str(tmp_path)) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_test_failures
|
||||
# ---------------------------------------------------------------------------
|
||||
_REPORT_FAIL = """---
|
||||
type: test-report
|
||||
work_item_id: ORCH-046
|
||||
result: FAIL
|
||||
---
|
||||
|
||||
# Test Report — ORCH-046
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12
|
||||
|
||||
## Результаты
|
||||
|
||||
| TC ID | Описание | Результат |
|
||||
|-------|----------|-----------|
|
||||
| TC-01 | парсер findings | PASS |
|
||||
| TC-09 | rollback task_desc | FAIL |
|
||||
|
||||
## Вывод pytest
|
||||
FAILED tests/test_stage_engine.py::TestReviewerRequestChanges::test_embed - AssertionError
|
||||
1 failed, 40 passed in 2.13s
|
||||
|
||||
## Итог
|
||||
FAIL
|
||||
"""
|
||||
|
||||
|
||||
class TestExtractTestFailures:
|
||||
def test_tc06_extracts_pytest_output(self, write_file):
|
||||
"""TC-06: relevant body excerpt (pytest output) from FAIL report (AC-2, AC-5)."""
|
||||
path = write_file("13-test-report.md", _REPORT_FAIL)
|
||||
out = extract_test_failures(path)
|
||||
assert "FAILED tests/test_stage_engine.py" in out
|
||||
assert "1 failed, 40 passed" in out
|
||||
|
||||
def test_priority_falls_back_to_fail_rows(self, write_file):
|
||||
"""No pytest section -> FAIL rows of the results table are used."""
|
||||
content = """---
|
||||
result: FAIL
|
||||
---
|
||||
|
||||
## Результаты
|
||||
|
||||
| TC ID | Описание | Результат |
|
||||
|-------|----------|-----------|
|
||||
| TC-01 | ok | PASS |
|
||||
| TC-09 | broken | FAIL |
|
||||
|
||||
## Итог
|
||||
FAIL
|
||||
"""
|
||||
out = extract_test_failures(write_file("13-test-report.md", content))
|
||||
assert "TC-09" in out
|
||||
assert "broken" in out
|
||||
# PASS rows are not failure-relevant.
|
||||
assert "TC-01" not in out
|
||||
|
||||
def test_priority_falls_back_to_itog(self, write_file):
|
||||
"""No pytest section and no FAIL rows -> Итог summary is used."""
|
||||
content = """---
|
||||
result: FAIL
|
||||
---
|
||||
|
||||
## Итог
|
||||
Регресс упал: смотрите CI лог.
|
||||
"""
|
||||
out = extract_test_failures(write_file("13-test-report.md", content))
|
||||
assert "Регресс упал" in out
|
||||
|
||||
def test_tc07_missing_file_returns_empty(self):
|
||||
"""TC-07: non-existent path -> '' without raising (AC-4)."""
|
||||
missing = os.path.join(tempfile.gettempdir(), "no-such-report-orch046.md")
|
||||
assert extract_test_failures(missing) == ""
|
||||
|
||||
def test_tc08_broken_or_empty_report_returns_empty(self, write_file):
|
||||
"""TC-08: empty / section-less report -> '' without raising (AC-4, AC-5)."""
|
||||
assert extract_test_failures(write_file("empty.md", "")) == ""
|
||||
no_sections = "---\nresult: FAIL\n---\n\n# Test Report\nничего полезного\n"
|
||||
assert extract_test_failures(write_file("nosec.md", no_sections)) == ""
|
||||
|
||||
def test_long_failures_truncated(self, write_file):
|
||||
"""Long pytest output is truncated to the limit with a marker."""
|
||||
big = "x" * 5000
|
||||
content = f"## Вывод pytest\n{big}\n"
|
||||
out = extract_test_failures(write_file("13-test-report.md", content))
|
||||
assert len(out) <= MAX_FAILURES_CHARS + len("\n…(truncated)")
|
||||
assert "…(truncated)" in out
|
||||
|
||||
def test_never_raises_on_directory_path(self, tmp_path):
|
||||
assert extract_test_failures(str(tmp_path)) == ""
|
||||
@@ -101,6 +101,14 @@ def _jobs():
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _job_contents():
|
||||
"""task_content of every enqueued job, oldest first (ORCH-046 task_desc check)."""
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT task_content FROM jobs ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def _add_developer_runs(task_id, n):
|
||||
conn = get_db()
|
||||
for _ in range(n):
|
||||
@@ -335,6 +343,179 @@ class TestTesterFail:
|
||||
assert _jobs() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-046: rollback task_desc carries verbatim reviewer/tester must-fix text
|
||||
# ---------------------------------------------------------------------------
|
||||
_REVIEW_MD = """---
|
||||
type: review
|
||||
work_item_id: ET-001
|
||||
verdict: REQUEST_CHANGES
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ET-001
|
||||
|
||||
## Summary
|
||||
Есть блокеры.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- [ ] Гонка в claim_next_job: отсутствует guard в WHERE
|
||||
|
||||
### P1 — Must fix
|
||||
- [ ] Нет обработки OSError при чтении отчёта
|
||||
|
||||
### P2 — Should fix
|
||||
- [ ] Переименовать blocks
|
||||
"""
|
||||
|
||||
_REPORT_MD = """---
|
||||
type: test-report
|
||||
work_item_id: ET-001
|
||||
result: FAIL
|
||||
---
|
||||
|
||||
# Test Report — ET-001
|
||||
|
||||
## Результаты
|
||||
|
||||
| TC ID | Описание | Результат |
|
||||
|-------|----------|-----------|
|
||||
| TC-09 | rollback | FAIL |
|
||||
|
||||
## Вывод pytest
|
||||
FAILED tests/test_stage_engine.py::TestTaskDescEmbedding - AssertionError
|
||||
1 failed, 50 passed in 3.01s
|
||||
|
||||
## Итог
|
||||
FAIL
|
||||
"""
|
||||
|
||||
|
||||
class TestRollbackTaskDescEmbedding:
|
||||
"""ORCH-046 AC-1/AC-2/AC-3/AC-4: the rollback task_desc embeds verbatim
|
||||
must-fix text (reviewer P0/P1, tester reason + report excerpt) plus the link.
|
||||
"""
|
||||
|
||||
def _patch_worktree(self, monkeypatch, tmp_path, work_item_id, filename, body):
|
||||
"""Make get_worktree_path resolve to tmp_path and seed the artifact file."""
|
||||
artifact = tmp_path / "docs" / "work-items" / work_item_id
|
||||
artifact.mkdir(parents=True, exist_ok=True)
|
||||
(artifact / filename).write_text(body, encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "get_worktree_path", lambda repo, branch: str(tmp_path)
|
||||
)
|
||||
|
||||
def test_tc09_reviewer_embeds_p0_p1_and_link(self, monkeypatch, tmp_path):
|
||||
"""TC-09: reviewer REQUEST_CHANGES -> task_desc has verbatim P0/P1 + link."""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_reviewer_verdict": _fail("verdict: REQUEST_CHANGES")},
|
||||
)
|
||||
self._patch_worktree(monkeypatch, tmp_path, "ET-001", "12-review.md", _REVIEW_MD)
|
||||
task_id = _make_task("review")
|
||||
advance_stage(task_id, "review", "enduro-trails", "ET-001",
|
||||
"feature/ET-001-x", finished_agent="reviewer")
|
||||
contents = _job_contents()
|
||||
assert len(contents) == 1
|
||||
desc = contents[0]
|
||||
# AC-1: verbatim P0/P1 findings.
|
||||
assert "Гонка в claim_next_job: отсутствует guard в WHERE" in desc
|
||||
assert "Нет обработки OSError при чтении отчёта" in desc
|
||||
# P2 must not leak.
|
||||
assert "Переименовать blocks" not in desc
|
||||
# AC-3: link to full file preserved.
|
||||
assert "docs/work-items/ET-001/12-review.md" in desc
|
||||
|
||||
def test_tc10_tester_embeds_reason_excerpt_and_link(self, monkeypatch, tmp_path):
|
||||
"""TC-10: tester FAIL -> task_desc has reason + report excerpt + link."""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_tests_passed": _fail("1 test failed")},
|
||||
)
|
||||
self._patch_worktree(
|
||||
monkeypatch, tmp_path, "ET-001", "13-test-report.md", _REPORT_MD
|
||||
)
|
||||
task_id = _make_task("testing")
|
||||
advance_stage(task_id, "testing", "enduro-trails", "ET-001",
|
||||
"feature/ET-001-x", finished_agent="tester")
|
||||
contents = _job_contents()
|
||||
assert len(contents) == 1
|
||||
desc = contents[0]
|
||||
# AC-2: gate reason present.
|
||||
assert "1 test failed" in desc
|
||||
# AC-2: report body excerpt (pytest output) present.
|
||||
assert "FAILED tests/test_stage_engine.py::TestTaskDescEmbedding" in desc
|
||||
# AC-3: link to full file preserved.
|
||||
assert "docs/work-items/ET-001/13-test-report.md" in desc
|
||||
|
||||
def test_tc11_reviewer_graceful_fallback_when_no_file(self, monkeypatch, tmp_path):
|
||||
"""TC-11: missing/broken 12-review.md -> graceful link-only fallback (AC-4)."""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_reviewer_verdict": _fail("verdict: REQUEST_CHANGES")},
|
||||
)
|
||||
# Worktree resolves but the review file does not exist.
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "get_worktree_path", lambda repo, branch: str(tmp_path)
|
||||
)
|
||||
task_id = _make_task("review")
|
||||
res = advance_stage(task_id, "review", "enduro-trails", "ET-001",
|
||||
"feature/ET-001-x", finished_agent="reviewer")
|
||||
# Rollback still happens exactly as before.
|
||||
assert res.rolled_back_to == "development"
|
||||
assert _stage(task_id) == "development"
|
||||
contents = _job_contents()
|
||||
assert len(contents) == 1
|
||||
desc = contents[0]
|
||||
# Falls back to the previous link-string behavior (no findings block).
|
||||
assert "Fix findings in docs/work-items/ET-001/12-review.md" in desc
|
||||
assert "Findings (P0/P1):" not in desc
|
||||
|
||||
def test_tc11_tester_graceful_fallback_keeps_reason(self, monkeypatch, tmp_path):
|
||||
"""AC-2/AC-4: missing report -> reason still present, link fallback."""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_tests_passed": _fail("2 tests failed")},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "get_worktree_path", lambda repo, branch: str(tmp_path)
|
||||
)
|
||||
task_id = _make_task("testing")
|
||||
advance_stage(task_id, "testing", "enduro-trails", "ET-001",
|
||||
"feature/ET-001-x", finished_agent="tester")
|
||||
desc = _job_contents()[0]
|
||||
assert "2 tests failed" in desc
|
||||
assert "docs/work-items/ET-001/13-test-report.md" in desc
|
||||
|
||||
def test_tc12_retry_and_rollback_behavior_unchanged(self, monkeypatch, tmp_path):
|
||||
"""TC-12 (AC-6): embedding does not change retry/rollback semantics.
|
||||
|
||||
4th developer attempt still alerts instead of enqueueing, even with a
|
||||
valid review file present.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_reviewer_verdict": _fail("verdict: REQUEST_CHANGES")},
|
||||
)
|
||||
self._patch_worktree(monkeypatch, tmp_path, "ET-001", "12-review.md", _REVIEW_MD)
|
||||
task_id = _make_task("review")
|
||||
_add_developer_runs(task_id, 3) # already at the cap
|
||||
res = advance_stage(task_id, "review", "enduro-trails", "ET-001",
|
||||
"feature/ET-001-x", finished_agent="reviewer")
|
||||
assert res.rolled_back_to == "development"
|
||||
assert res.alerted is True
|
||||
assert stage_engine.send_telegram.called
|
||||
# No new developer job past the cap, regardless of embedding.
|
||||
assert _jobs() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BUG 8: deploy verdict gates deploy -> done (not the LLM exit code)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
151
tests/test_staging_check_b6.py
Normal file
151
tests/test_staging_check_b6.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""ORCH-048: unit tests for the B6 registry-isolation verdict in staging_check.py.
|
||||
|
||||
B6 «Registry: sandbox present, prod ET/ORCH absent» is the staging-isolation
|
||||
safety check. Its verdict logic is isolated into the pure function
|
||||
``_evaluate_b6(known) -> (passed, detail)`` so both outcomes (clean staging
|
||||
registry → PASS, polluted registry → FAIL) can be tested without standing up a
|
||||
live staging instance or docker (02-trz §9, ADR-001).
|
||||
|
||||
These tests target that pure function plus the deterministic-degradation path
|
||||
(``_run_b6``) and statically assert the host-path hack is gone (TR-6 / TC-06).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load scripts/staging_check.py by path (scripts/ is not an importable package).
|
||||
# ---------------------------------------------------------------------------
|
||||
_SCRIPT_PATH = (
|
||||
pathlib.Path(__file__).resolve().parent.parent / "scripts" / "staging_check.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_module():
|
||||
spec = importlib.util.spec_from_file_location("staging_check", _SCRIPT_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
sc = _load_module()
|
||||
|
||||
SANDBOX = sc.SANDBOX_PROJECT_ID
|
||||
PROD_ET = sc.PROD_ET_PROJECT_ID
|
||||
PROD_ORCH = sc.PROD_ORCH_PROJECT_ID
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01 — clean staging registry → PASS
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc01_clean_registry_passes():
|
||||
passed, detail = sc._evaluate_b6({SANDBOX})
|
||||
assert passed is True
|
||||
assert "sandbox=YES" in detail
|
||||
assert "prod-ET=NO(good)" in detail
|
||||
assert "prod-ORCH=NO(good)" in detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-02 — prod-ET leaked into registry → FAIL
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc02_prod_et_present_fails():
|
||||
passed, detail = sc._evaluate_b6({SANDBOX, PROD_ET})
|
||||
assert passed is False
|
||||
assert "sandbox=YES" in detail
|
||||
assert "prod-ET=YES(BAD!)" in detail
|
||||
assert "prod-ORCH=NO(good)" in detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-03 — prod-ORCH leaked into registry → FAIL
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc03_prod_orch_present_fails():
|
||||
passed, detail = sc._evaluate_b6({SANDBOX, PROD_ORCH})
|
||||
assert passed is False
|
||||
assert "sandbox=YES" in detail
|
||||
assert "prod-ET=NO(good)" in detail
|
||||
assert "prod-ORCH=YES(BAD!)" in detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-04 — sandbox absent (empty registry) → deterministic FAIL, no exception
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc04_empty_registry_fails_without_sandbox():
|
||||
passed, detail = sc._evaluate_b6(set())
|
||||
assert passed is False
|
||||
assert "sandbox=NO" in detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-05 — both prod projects leaked → FAIL
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc05_both_prod_present_fails():
|
||||
passed, detail = sc._evaluate_b6({SANDBOX, PROD_ET, PROD_ORCH})
|
||||
assert passed is False
|
||||
assert "prod-ET=YES(BAD!)" in detail
|
||||
assert "prod-ORCH=YES(BAD!)" in detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-06 — registry source no longer depends on the host-path hack (TR-6)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc06_no_host_path_hack_in_source():
|
||||
source = _SCRIPT_PATH.read_text(encoding="utf-8")
|
||||
# The host-worktree path injection and the env-of-the-launcher reload that
|
||||
# caused the false FAIL must be gone from the B6 mechanics.
|
||||
assert 'sys.path.insert(0, "/repos/orchestrator")' not in source
|
||||
assert "importlib.reload" not in source
|
||||
|
||||
|
||||
def test_tc06_registry_loader_uses_src_projects():
|
||||
# The verdict input is built from src.projects.known_plane_project_ids()
|
||||
# resolved via the running instance's own PYTHONPATH/env — not from a
|
||||
# host-path-injected import. We verify the loader delegates to that function.
|
||||
import src.projects as projects_mod
|
||||
|
||||
sentinel = {"sentinel-id"}
|
||||
original = projects_mod.known_plane_project_ids
|
||||
projects_mod.known_plane_project_ids = lambda: sentinel
|
||||
try:
|
||||
known = sc._known_project_ids_from_registry()
|
||||
finally:
|
||||
projects_mod.known_plane_project_ids = original
|
||||
assert known == sentinel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-07 — degraded registry source → deterministic FAIL (not false PASS, not raise)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc07_source_failure_is_deterministic_fail(monkeypatch):
|
||||
def _boom():
|
||||
raise RuntimeError("registry import blew up")
|
||||
|
||||
monkeypatch.setattr(sc, "_known_project_ids_from_registry", _boom)
|
||||
|
||||
results = sc.Results()
|
||||
# Must not raise.
|
||||
sc._run_b6(results)
|
||||
|
||||
assert len(results._items) == 1
|
||||
label, passed, detail = results._items[0]
|
||||
assert passed is False
|
||||
assert "registry source unavailable" in detail
|
||||
assert "registry import blew up" in detail
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_b6 happy path wiring (clean registry → PASS result recorded)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_run_b6_records_pass_for_clean_registry(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
sc, "_known_project_ids_from_registry", lambda: {SANDBOX}
|
||||
)
|
||||
results = sc.Results()
|
||||
sc._run_b6(results)
|
||||
assert len(results._items) == 1
|
||||
_label, passed, detail = results._items[0]
|
||||
assert passed is True
|
||||
assert "sandbox=YES" in detail
|
||||
Reference in New Issue
Block a user