diff --git a/.env.example b/.env.example index e3c34c3..d5d4305 100644 --- a/.env.example +++ b/.env.example @@ -107,6 +107,30 @@ ORCH_AGENT_EFFORT_DEPLOYER=medium # (G4 NOT enabled, ADR-001 ORCH-74: determinism — all agents stay on opus-4-8). A # non-empty value is validated by the SAME predicate as the model; a typo is dropped. ORCH_AGENT_FALLBACK_MODEL= + +# ── Agent timeout / wall-clock budgets (ORCH-7, raised per-role ORCH-109) ───── +# The in-process watchdog kills a run that exceeds its wall-clock budget +# (SIGTERM -> grace -> SIGKILL, exit_code=-9). _resolve_timeout ladder (highest +# first): OVERRIDES_JSON[agent] > dedicated role key > SECONDS (global default). +# SECONDS -> global default budget for every role WITHOUT a raised +# key (analyst/architect/tester/deployer). +# KILL_GRACE_SECONDS -> pause between SIGTERM and SIGKILL so claude can flush +# artifacts before the hard kill. +# OVERRIDES_JSON -> optional per-agent override object, e.g. +# {"reviewer":3600,"architect":2700}; wins for ANY role. +# Malformed JSON -> ignored + WARNING (never-break). +# ORCH-109: the two HEAVY roles get raised dedicated budgets (defaults = prod, so an +# empty .env reproduces prod — ORCH-101 canon). A non-positive value falls back to +# SECONDS + WARNING. +# DEVELOPER_S -> developer budget (xhigh, coding/agentic bottleneck), 60m. +# REVIEWER_S -> reviewer budget (large diff + high reasoning), 50m. +# CROSS-INVARIANT (ORCH-065): ORCH_REAPER_MAX_RUNNING_S MUST stay > max(budget)+grace; +# it is raised to 5400 in lockstep below (5400 > 3600 + 20 = 3620). +ORCH_AGENT_TIMEOUT_SECONDS=1800 +ORCH_AGENT_KILL_GRACE_SECONDS=20 +ORCH_AGENT_TIMEOUT_OVERRIDES_JSON= +ORCH_AGENT_TIMEOUT_DEVELOPER_S=3600 +ORCH_AGENT_TIMEOUT_REVIEWER_S=3000 # ORCH-042/ORCH-067: live-tracker mode. bump (DEFAULT since ORCH-067) -> on every # update the old card is deleted and a fresh one is sent silently to the BOTTOM of # the chat (deleteMessage + sendMessage + repoint), so the current status is always @@ -365,6 +389,8 @@ ORCH_PLANE_STATES_TTL_S=300 # REAPER_INTERVAL_S -> background scan period (seconds). # REAPER_DEAD_TICKS -> consecutive dead-pid ticks before reaping (Tier-1, >=2). # REAPER_MAX_RUNNING_S -> Tier-3 backstop ceiling; must exceed max agent_timeout+grace. +# ORCH-109: raised 3600 -> 5400 in lockstep with the developer +# budget (5400 > 3600 + 20 = 3620). # REAPER_FINALIZE_GRACE_S -> Tier-2 grace: how long agent_runs.exit_code must have been # recorded before a still-'running' job is reaped; MUST exceed # the max finalization window (git push + PR + Plane comments). @@ -374,7 +400,7 @@ ORCH_PLANE_STATES_TTL_S=300 ORCH_REAPER_ENABLED=true ORCH_REAPER_INTERVAL_S=60 ORCH_REAPER_DEAD_TICKS=2 -ORCH_REAPER_MAX_RUNNING_S=3600 +ORCH_REAPER_MAX_RUNNING_S=5400 ORCH_REAPER_FINALIZE_GRACE_S=300 ORCH_LEASE_RECLAIM_ENABLED=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 51aa89e..45529da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Timeout-бюджеты developer/reviewer + launch-стамп модели в телеметрии** (ORCH-109, `fix`): две аддитивные изолированные правки подсистемы запуска агентов (инцидент ORCH-104, runs 658/659/660), **без** касания `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схемы БД. ADR: `docs/work-items/ORCH-109/06-adr/ADR-001-agent-timeout-budgets-and-launch-model-stamp.md`, сквозной `docs/architecture/adr/adr-0040-agent-timeout-budgets-and-launch-model-stamp.md`. + - **Launch-стамп модели (D1, FR-1):** резолвенная `resolve_agent_model(...)` пишется в `agent_runs.model` в **момент launch** объединённым `UPDATE agent_runs SET model=?, effort=? WHERE id=?` рядом со стампом эффорта (ORCH-087) в `launcher._spawn`. Раньше модель писалась только постфактум из финального usage-JSON (`record_usage`, `model=COALESCE(?, model)`), а убитый по тайм-ауту прогон этот JSON не эмитит → модель оставалась `NULL` ровно тогда, когда нужна для разбора инцидента. Теперь модель присутствует с launch, **переживает timeout-kill (`exit_code=-9`)**, видна in-flight в `GET /metrics`/`GET /queue` (`get_running_agents` уже отдаёт `model`) и в строке Telegram-карточки. Пустой резолв (CLI-дефолт без `--model`) → `NULL` (симметрично `effort or None`). Постфактум `record_usage` остаётся **обогащением** (COALESCE сохраняет launch-стамп при `model=None`). never-raise: сбой стампа изолирован `try/except` + WARNING, launch продолжается. + - **Поднятые per-role wall-clock бюджеты (D3/D4, FR-3):** выделенные типизированные ключи `agent_timeout_developer_s=3600` (60м) / `agent_timeout_reviewer_s=3000` (50м) (env `ORCH_AGENT_TIMEOUT_DEVELOPER_S`/`_REVIEWER_S`). `_resolve_timeout(agent)` получил детерминированную лестницу: `agent_timeout_overrides_json[agent]` (операторский escape-hatch, высший приоритет, BC) → выделенный ключ роли → `agent_timeout_seconds=1800` (прочие роли — байт-в-байт). Малформный JSON / непозитивный/нечисловой выделенный ключ → откат на глобальный дефолт + WARNING (never-break). Дефолты = боевым значениям (канон ORCH-101): пустой `.env` воспроизводит поднятые бюджеты. **Кросс-инвариант reaper ORCH-065** сохранён синхронным поднятием `reaper_max_running_s` 3600 → **5400** (`5400 > max(timeout)3600 + grace20 = 3620`). + - **FR-4/NFR-6 (видимость при kill / in-flight) и FR-5 (анти-salvage) — структурно уже выполнены** существующим кодом (продвижение гейтится `if exit_code == 0`, timeout-kill → `_finalize_job` retry/fail, не advance); ORCH-109 фиксирует их **регресс-тестами**, новых ветвей не вводит. Покрытие — новый `tests/test_orch109_timeout_model.py` (TC-01…TC-12, детерминированный, без сети/CLI). Обновлены `tests/test_config.py` (reaper-дефолт 5400) и `tests/test_launcher.py` (ладдер `_resolve_timeout`). Документация — `.env.example` (блок agent-timeout + reaper), `config.py`-паспорт, `docs/architecture/README.md`/`internals.md` + front-page `README.md` (раздел «Watchdog») (per-role бюджеты). - **Презентация: слайды Lite-установки и использования через Plane** (ORCH-105, `docs`): слайдо-источник `docs/overview/presentation.md` расширен тремя слайдами в каноне ORCH-011 (16 → 19, сквозная нумерация сохранена): один слайд про **Lite-установку скриптами** (два контейнера платформы — оркестратор + сторож на инфре заказчика; развёртывание без правки кода, только конфиг; помощники `gen_secrets.py`/`onboard_project.py` + `docker compose up -d`; runbook `LITE_SETUP.md` с проверкой каждого шага; одношаговый bootstrap — это смежный Bundled, не Lite) и два слайда оператор-инструкции **«как пользоваться орком через Plane»** (запуск через статус «To Analyse»; статусы Plane — индикация, не управление; оба человеческих гейта «Approved»/«Confirm Deploy»; авто-лейблы `autoApprove`/`autoDeploy`/`Bug` — снимают только человеческие решения, ни одна техническая проверка не пропускается; отмена через «STOP»; наблюдение — статусы доски + живая Telegram-карточка + комментарии со ссылками на ветку/PR). Факты сверены с golden sources (`docs/deployment/LITE_SETUP.md`, `docs/overview/tech-pipeline.md`, `tech-integrations.md`, `CLAUDE.md`). **Docs+tests only:** `src/**`/`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схема БД — байт-в-байт; новый QG не вводится; `python-pptx` не добавлен в прод-образ; собранный `.pptx` в git не коммитится. Анти-дрейф — новая функция `test_presentation_covers_lite_and_plane_usage_bits` в `tests/test_system_docs.py` (существующие проверки без послаблений). ADR: `docs/work-items/ORCH-105/06-adr/ADR-001-presentation-lite-and-plane-usage-slides.md` (канон витрины не меняется — `adr-0039-system-overview-docs-canon.md`). - **Витрина системы `docs/overview/`: бизнес + тех, маршруты трёх аудиторий, презентация** (ORCH-011, `docs`): единая точка входа в документацию платформы — новый docs-раздел `docs/overview/` (плоский каталог, 10 файлов, ADR-001 D1): индекс `README.md` (маршруты «Я заказчик / Я менеджер / Я разработчик» + норматив сопровождения «изменил функциональность → обнови витрину в том же PR»), бизнес-часть `business.md` (проблема → решение → что умеет фактически → ценность → 6 сценариев; без жаргона, цифры только с атрибуцией), 7 тех-блоков `tech-*.md` (архитектура со схемой потока, конвейер/гейты, агенты, модель объектов, интеграции, качество/безопасность, наблюдаемость; link-first — за деталями ссылки в golden sources, разрешённый дубль только машинно-сверяемый). **Docs+tests+dev-скрипт** (паттерн ORCH-102/103): `src/**`/`docker-compose.yml`/`Dockerfile`/`requirements*`/`STAGE_TRANSITIONS`/`QG_CHECKS`/machine-verdict/схема БД — ноль изменений. ADR: `docs/work-items/ORCH-011/06-adr/ADR-001-system-overview-canon.md`, сквозной `adr-0039-system-overview-docs-canon.md`. - **Презентация (D4/D5):** слайдо-источник `docs/overview/presentation.md` (16 слайдов в машинно-парсимой структуре «## Слайд N: …» + процедура сборки «команда + Проверка:») + dev-скрипт `scripts/build_presentation.py` (python-pptx, тёмный дизайн, редактируемый текст с точной кириллицей; чистый stdlib-парсер `parse_slides` + ленивый импорт pptx). Запуск только вне рантайма; `python-pptx` НЕ в прод-образе (машинный гард); собранный `.pptx` в git не коммитится — `build/` в `.gitignore`. diff --git a/CLAUDE.md b/CLAUDE.md index 94b8fd1..ccb063f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ ## Стек - Backend: FastAPI + uvicorn (Python 3.12) - БД: SQLite (`src/db.py`) -- Агенты: Claude CLI (`ORCH_CLAUDE_BIN`), по одному промпту на роль в `.openclaw/agents/`. **ORCH-74:** модель/эффорт агента берутся ТОЛЬКО из config (`resolve_agent_model`/`resolve_agent_effort`, ORCH-41) — frontmatter `model:` удалён как мёртвый, frontmatter описательный; имя модели валидируется форматом `^claude-…$` перед `--model` (never-break). **ORCH-077 (52d, замыкает эпик 52):** тело всех 6 промптов переписано в едином **каноне Anthropic** (5 обязательных XML-секций в нормативном порядке ``→``→``→``→``, запреты в формате «❌ X → ✅ Y», `` у решающих ролей), и каждый промпт **добровольно** эмитит 6-польную frontmatter-схему 52c (`work_item`/`stage`/`author_agent`/`status`/`created_at`/`model_used`) **аддитивно** — рядом с machine-verdict ключом, НЕ меняя его имя/регистр/значения (`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:` — байт-в-байт). Это **docs/prompts-only** изменение: `src/**`/`STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД не тронуты; `frontmatter_validation_strict` остаётся `False` (enforcement НЕ включён). Промпт `cat`-ается из worktree в момент запуска → новые промпты вступают в силу на следующем worktree от `main` без прод-рестарта. Анти-регресс — структурные тесты `tests/test_agent_prompts_canon.py` + зелёный `test_agent_frontmatter_no_model.py`. **Норматив на будущее:** новые/изменённые агент-промпты следуют этому канону. Детали — `docs/architecture/adr/adr-0021-prompt-canon-anthropic.md`. **ORCH-092 (эпилог эпика 52, docs/prompts-only):** аудит 6 промптов поверх канона — копируемые frontmatter-примеры расхардкожены (`created_at: `/`model_used: ` + врезка «подставь `date +%F`/модель из конфига, не копируй буквально»; литерал `claude-opus-4-8` — только справка в таблице полей); добавлена секция `` developer/reviewer/tester (после ``, порядок 5 секций цел); developer лишён ручного `git rebase origin/main` (свежесть базы — инвариант движка serial-gate ORCH-088 + `auto_rebase_onto_main` под merge-lease; ручной rebase конфликтовал с запретом force-push — ADR-001 D1); tester обогащён worktree-путём + smoke `serial_gate` + покрытием каждого TC; из reviewer удалена мёртвая строка «тот же экземпляр Developer». **Языковое исключение (нормативно, ADR-001 D2):** `deployer.md` сознательно остаётся на **английском** (5 ru + 1 en) как самый safety-critical промпт — НЕ «чинить» язык вслепую; критичные self-hosting-запреты подняты в видную рамку. Verdict-ключи и канон 52d — байт-в-байт; анти-регресс — `tests/test_agent_prompts_canon.py` (ORCH-092 TC-01…TC-08). Детали — `docs/work-items/ORCH-092/06-adr/ADR-001-developer-rebase-and-deployer-language.md`. +- Агенты: Claude CLI (`ORCH_CLAUDE_BIN`), по одному промпту на роль в `.openclaw/agents/`. **ORCH-74:** модель/эффорт агента берутся ТОЛЬКО из config (`resolve_agent_model`/`resolve_agent_effort`, ORCH-41) — frontmatter `model:` удалён как мёртвый, frontmatter описательный; имя модели валидируется форматом `^claude-…$` перед `--model` (never-break). **ORCH-077 (52d, замыкает эпик 52):** тело всех 6 промптов переписано в едином **каноне Anthropic** (5 обязательных XML-секций в нормативном порядке ``→``→``→``→``, запреты в формате «❌ X → ✅ Y», `` у решающих ролей), и каждый промпт **добровольно** эмитит 6-польную frontmatter-схему 52c (`work_item`/`stage`/`author_agent`/`status`/`created_at`/`model_used`) **аддитивно** — рядом с machine-verdict ключом, НЕ меняя его имя/регистр/значения (`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:` — байт-в-байт). Это **docs/prompts-only** изменение: `src/**`/`STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД не тронуты; `frontmatter_validation_strict` остаётся `False` (enforcement НЕ включён). Промпт `cat`-ается из worktree в момент запуска → новые промпты вступают в силу на следующем worktree от `main` без прод-рестарта. Анти-регресс — структурные тесты `tests/test_agent_prompts_canon.py` + зелёный `test_agent_frontmatter_no_model.py`. **Норматив на будущее:** новые/изменённые агент-промпты следуют этому канону. Детали — `docs/architecture/adr/adr-0021-prompt-canon-anthropic.md`. **ORCH-092 (эпилог эпика 52, docs/prompts-only):** аудит 6 промптов поверх канона — копируемые frontmatter-примеры расхардкожены (`created_at: `/`model_used: ` + врезка «подставь `date +%F`/модель из конфига, не копируй буквально»; литерал `claude-opus-4-8` — только справка в таблице полей); добавлена секция `` developer/reviewer/tester (после ``, порядок 5 секций цел); developer лишён ручного `git rebase origin/main` (свежесть базы — инвариант движка serial-gate ORCH-088 + `auto_rebase_onto_main` под merge-lease; ручной rebase конфликтовал с запретом force-push — ADR-001 D1); tester обогащён worktree-путём + smoke `serial_gate` + покрытием каждого TC; из reviewer удалена мёртвая строка «тот же экземпляр Developer». **Языковое исключение (нормативно, ADR-001 D2):** `deployer.md` сознательно остаётся на **английском** (5 ru + 1 en) как самый safety-critical промпт — НЕ «чинить» язык вслепую; критичные self-hosting-запреты подняты в видную рамку. Verdict-ключи и канон 52d — байт-в-байт; анти-регресс — `tests/test_agent_prompts_canon.py` (ORCH-092 TC-01…TC-08). Детали — `docs/work-items/ORCH-092/06-adr/ADR-001-developer-rebase-and-deployer-language.md`. **ORCH-109 (timeout-бюджеты + launch-стамп модели, инцидент ORCH-104):** две аддитивные изолированные правки `launcher` (без касания `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схемы БД). (1) **Launch-стамп модели:** резолвенная `resolve_agent_model` пишется в `agent_runs.model` в момент launch объединённым `UPDATE … SET model=?, effort=?` рядом со стампом эффорта (ORCH-087) → модель видна не-`null` при ЛЮБОМ исходе, включая timeout-kill (`exit_code=-9`), и in-flight в `GET /metrics`/`GET /queue`; постфактум `record_usage` (`model=COALESCE(?, model)`) остаётся обогащением (не затирает launch-стамп при `model=None`); пустой резолв → `NULL`; never-raise. (2) **Поднятые per-role бюджеты:** выделенные ключи `agent_timeout_developer_s=3600`/`agent_timeout_reviewer_s=3000` (env `ORCH_AGENT_TIMEOUT_DEVELOPER_S`/`_REVIEWER_S`); лестница `_resolve_timeout`: `agent_timeout_overrides_json[agent]` → выделенный ключ роли → `agent_timeout_seconds=1800` (прочие роли — байт-в-байт; малформный/непозитивный конфиг → дефолт + WARNING, never-break). Инвариант reaper ORCH-065 сохранён синхронным поднятием `reaper_max_running_s` 3600→**5400** (`5400 > 3600+20=3620`). FR-5 анти-salvage — структурно (продвижение гейтится `if exit_code==0`, timeout-kill → `_finalize_job` retry/fail), зафиксировано регресс-тестом; новых ветвей нет. Покрытие — `tests/test_orch109_timeout_model.py`. Детали — `docs/work-items/ORCH-109/06-adr/ADR-001-agent-timeout-budgets-and-launch-model-stamp.md`, сквозной `docs/architecture/adr/adr-0040-agent-timeout-budgets-and-launch-model-stamp.md`. - Очередь задач: собственная (SQLite `jobs`, `src/queue_worker.py`, ORCH-1). **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота `max_concurrency`; декларации/детект циклов — leaf `src/task_deps.py` (kill-switch `ORCH_TASK_DEPS_ENABLED`). Сериализация мержа одного репо — безусловный pre-merge rebase под merge-lease (`ORCH_PREMERGE_REBASE_ALWAYS`). **ORCH-088 (serial gate, Этап 1):** новая задача репо не входит в `analysis` (analyst-job не выбирается, ветка не режется), пока в репо есть **более ранняя** незавершённая задача (`t2.id < jobs.task_id`, FIFO) ИЛИ репо заморожен (`repo_freeze`). Срез ветки **отложен** со `start_pipeline` на момент claim analyst-job (`launcher._materialize_deferred_branch`) — база = свежий `origin/main` с кодом предшественника (анти-stale-base). Post-deploy `DEGRADED` → durable per-repo freeze (`repo_freeze`, `cleared_at IS NULL` = активен) + Telegram; снятие — вручную `POST /serial-gate/unfreeze?repo=…`. Leaf `src/serial_gate.py` (claim — fail-OPEN, freeze — fail-CLOSED); флаги `ORCH_SERIAL_GATE_ENABLED` (kill-switch), `ORCH_SERIAL_GATE_REPOS` (CSV; пусто = все репо), `ORCH_SERIAL_GATE_FREEZE_ENABLED`. Блок `serial_gate` в `GET /queue`. `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты. **ORCH-093 (merge-актор устойчив к икоте Gitea):** детерминированный merge-актор под-гейта `deploy → done` (`src/merge_gate.py`) ретраит **транзиентные** ошибки Gitea вместо ложного HOLD (инцидент ORCH-063: `POST …/merge` → `405 "try again later"` сразу после пуша). `merge_pr` оборачивает **только** мутирующий `POST …/merge` в ограниченный retry-loop с экспоненциальным backoff (`min(base*2^(i-1), max)`, потолок суммарного сна `(N-1)*max ≤ 10 с`); классификатор `_classify_merge_response`: транзиент (ретрай) — `405`/`408`/`5xx`/таймаут/сетевая + `409`/`422` при `mergeable==True` (доп. `GET /pulls/{index}`; `mergeable==None` → дефолт-транзиент, fail-OPEN-в-ретрай), терминал (быстрый честный `False`, защита ORCH-071/073 как прежде) — `403`/`404`/реальный конфликт (`mergeable==False`). Kill-switch `merge_retry_enabled=false` → ровно один POST (байт-в-байт прежнее one-shot); флаги `ORCH_MERGE_RETRY_*` (`max_attempts=3`, `backoff_base_s=2`, `backoff_max_s=5`). Гард **already-in-main** в `ensure_open_pr` (leaf `_branch_fully_in_main`, `git merge-base --is-ancestor HEAD origin/main`): ветка целиком в `main` → исход `"already-in-main"` без создания мусорного пустого PR; `_handle_merge_verify` пропускает `merge_pr` и отдаёт авторитетному SHA-в-main довести до `done` (НЕ HOLD); git-ошибка → fail-OPEN на create-путь. Без отдельного флага (накрыт `merge_verify_autocreate_pr_enabled`). INV-4 (мерж только через Gitea PR-merge API, никогда push/force-push в `main`), never-raise, `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — сохранены. Детали — `docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md`, сквозной `docs/architecture/adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md`. - Контейнеризация: Docker + Compose - CI/CD: Gitea Actions (`.gitea/workflows/`) diff --git a/README.md b/README.md index a2b1904..2a46132 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,7 @@ Task-файлы `.task-*.md` пишутся **прямой записью в с stdout/stderr агента перенаправляются СРАЗУ в `/app/data/runs/{id}.log` на уровне ОС (без PIPE). monitor-поток делает `proc.wait()` → реальный exit_code, нет зомби. ### Watchdog -Каждый агент имеет timeout 30 минут. При превышении — SIGKILL + запись exit_code=-9. +Каждый агент имеет per-role wall-clock бюджет (ORCH-109): developer 60 мин / reviewer 50 мин / прочие 30 мин дефолт (`_resolve_timeout`). При превышении — SIGTERM→grace→SIGKILL + запись exit_code=-9. Tier-3 backstop `reaper_max_running_s`=90 мин > max(timeout)+grace (ORCH-065). ### Event routing Gitea events роутятся по типу: diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 1a50dfb..57afe88 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -9,7 +9,7 @@ - **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. Модель/эффорт каждого агента резолвятся из config (`resolve_agent_model`/`resolve_agent_effort`, ORCH-41), а не из frontmatter промпта. **ORCH-74:** имя модели валидируется форматом `^claude-…$` (`is_valid_model`) перед `--model`; невалидное → лог + откат на следующий уровень/CLI-дефолт (never-break, как `VALID_EFFORTS` для эффорта). Тот же предикат гардит inline-чтение `--fallback-model`. +- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance. Модель/эффорт каждого агента резолвятся из config (`resolve_agent_model`/`resolve_agent_effort`, ORCH-41), а не из frontmatter промпта. **ORCH-74:** имя модели валидируется форматом `^claude-…$` (`is_valid_model`) перед `--model`; невалидное → лог + откат на следующий уровень/CLI-дефолт (never-break, как `VALID_EFFORTS` для эффорта). Тот же предикат гардит inline-чтение `--fallback-model`. **ORCH-109 ([adr-0040](adr/adr-0040-agent-timeout-budgets-and-launch-model-stamp.md)):** (1) резолвенная **модель стампится в `agent_runs.model` в момент launch** (`_spawn`, объединённый `UPDATE … SET model=?, effort=?` рядом со стампом эффорта ORCH-087; пустой резолв → `NULL`; never-raise) → модель видна не-`null` при любом исходе прогона, включая timeout-kill (`exit_code=-9`), и in-flight в `GET /metrics`/`GET /queue` (`get_running_agents` уже отдаёт `model`); постфактум `record_usage` (`model=COALESCE(?, model)`) остаётся **обогащением**, не единственным источником истины. (2) **Per-role wall-clock бюджеты** через выделенные ключи `agent_timeout_developer_s=3600`/`agent_timeout_reviewer_s=3000` (лестница `_resolve_timeout`: `agent_timeout_overrides_json` → выделенный ключ роли → `agent_timeout_seconds=1800`; прочие роли — байт-в-байт; малформный/вне-диапазонный конфиг → дефолт + WARNING). Инвариант reaper ORCH-065 сохранён синхронным поднятием `reaper_max_running_s` 3600→**5400** (`5400 > max(timeout)3600 + grace20`). FR-5 анти-salvage — структурно: продвижение гейтится `if exit_code==0`, timeout-kill → `_finalize_job` (retry/fail), не advance. `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД не тронуты. - **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe. **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота; декларации/циклы — leaf `src/task_deps.py`. - **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts`/`agent_effort_`): `agent_timeout_developer_s=3600`, + `agent_timeout_reviewer_s=3000`. Лестница `_resolve_timeout`: `agent_timeout_overrides_json[agent]` + (escape-hatch, высший) → выделенный ключ роли → `agent_timeout_seconds=1800` (прочие роли — + байт-в-байт). never-break: малформный JSON / вне-диапазонный ключ → откат на глобальный дефолт + + WARNING. +- **Синхронное поднятие reaper (инвариант ORCH-065).** `reaper_max_running_s`: **3600 → 5400**. + Проверка `reaper_max_running_s > max(timeout) + agent_kill_grace_seconds`: `5400 > 3600 + 20 = 3620` + ✓ (запас 1780s, покрывает окно финализации монитора). `5400 < ` sidecar `stage_stuck_s`=7200 → + легитимный длинный developer-прогон не порождает ложный `stage_stuck`-алерт. +- **Канон дефолтов (ORCH-101).** Дефолт каждого ключа = боевому значению → пустой `.env` + воспроизводит прод-поведение (в т.ч. поднятые бюджеты). «Байт-в-байт прежнее» (NFR-1) строго + применяется к ролям вне `{developer, reviewer}`. +- **FR-5 анти-salvage — структурно, без нового кода.** Продвижение стадии гейтится + `if exit_code == 0: _try_advance_stage(...)`; timeout-kill (-9) → `_finalize_job` → retry/fail, + никогда не advance. Добавляется регресс-тест, не новая ветвь. + +## Альтернативы +- **Дефолт `agent_timeout_overrides_json={"developer":…}`** — отвергнуто: ломает канон ORCH-101 + непустым JSON-дефолтом, хрупкая строка против типизированного int, нельзя override одной env-роли. +- **Бюджеты ≤ 3580 без поднятия reaper** — рассмотрено (меньший blast-radius), отвергнуто как + доминирующее: урезает самую тяжёлую роль ради статичности backstop-числа; NFR-4 явно делегирует + reaper-поднятие архитектору. Остаётся операторским запасным путём (всё env-override'имо). +- **Repo-scoped бюджеты (`*_repos`)** — отвергнуто: тайм-аут — свойство launch, не гейт-решение; + глобальность благоприятна enduro. +- **Новый guard-leaf анти-salvage** — отвергнуто: продвижение уже гейтится exit-кодом; новый код = + лишняя ветвь риска. + +## Последствия +- Модель видна (не `null`) при любом исходе прогона (трекер / status-комментарии / `/metrics` / + `/queue`) — ключевой контекст инцидента доступен в момент сбоя; тяжёлые роли получают реальный + бюджет (developer ×2, reviewer +67%) → меньше ложных timeout-kill при автономном прогоне (ORCH-088). +- Аддитивно/обратимо/never-raise; гейты/схема/machine-verdict/деплой-путь не тронуты; прод-контейнер + не рестартится (self-hosting безопасность, NFR-5). +- Плата: Tier-3 backstop 60→90м (реально зависший прогон держится дольше — митигейшн Tier-1/Tier-2 + + watchdog ≤ бюджета); глобальность поднимает enduro-роли (благоприятно; reaper-страховка цела); + sidecar `agent_hung` (alert-only) может чаще срабатывать на здоровых длинных прогонах с low-CPU + фазами (не влияет на конвейер). +- **Откат:** занизить `ORCH_AGENT_TIMEOUT_DEVELOPER_S`/`_REVIEWER_S` (= 1800) и вернуть + `ORCH_REAPER_MAX_RUNNING_S=3600`; launch-стамп модели отката не требует. Kill-switch не вводится + (нет рисковых ветвей: стамп безопасен, тайм-аут fail-safe на дефолт). + +## Связи +adr-0011 (job-reaper — Tier-3 backstop `reaper_max_running_s`, инвариант ORCH-065 правится здесь +синхронно), adr-0030 (metrics-endpoint — `get_running_agents().model` начинает заполняться для +running-job), adr-0033 (sidecar-watchdog — `agent_hung`/`stage_stuck` пороги, alert-only), +adr-0036 (replication foundation — канон «дефолт = боевое значение»). Маркер-инварианты: ORCH-065, +ORCH-087, ORCH-101. diff --git a/docs/architecture/internals.md b/docs/architecture/internals.md index ec4efcb..3373331 100644 --- a/docs/architecture/internals.md +++ b/docs/architecture/internals.md @@ -93,7 +93,7 @@ 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) +3. Стартует **watchdog thread** (per-role wall-clock бюджет → SIGTERM→grace→SIGKILL по pid; ORCH-109: developer 60 мин / reviewer 50 мин / прочие 30 мин дефолт, `_resolve_timeout`) 4. Стартует **monitor thread**: `proc.wait()` (гарантированный reap → реальный exit_code в БД) → закрывает log_fh → git commit/push → auto-advance ### 5. Auto-advance (`launcher._try_advance_stage`) @@ -259,7 +259,7 @@ services: | Механизм | Описание | |----------|----------| -| Watchdog | Каждый агент: timeout 30 мин → SIGKILL + exit_code=-9 | +| Watchdog | Per-role wall-clock бюджет (ORCH-109): developer 60 мин / reviewer 50 мин / прочие 30 мин (`_resolve_timeout`) → SIGTERM→grace→SIGKILL + exit_code=-9. Tier-3 backstop `reaper_max_running_s`=90 мин > max(timeout)+grace (ORCH-065) | | safe.directory | git операции работают в любой директории | | Max retries | Developer: max 3 попытки, затем эскалация | | Zombie-free | stdout идёт сразу в файл + monitor `proc.wait()` → процесс всегда reap'нут (B-2) | diff --git a/docs/work-items/ORCH-109/00-business-request.md b/docs/work-items/ORCH-109/00-business-request.md new file mode 100644 index 0000000..29126ca --- /dev/null +++ b/docs/work-items/ORCH-109/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: ORCH: timeout budgets + launch-time model telemetry for developer/reviewer + +Work Item ID: ORCH-109 + +## Description + +TBD diff --git a/docs/work-items/ORCH-109/01-brd.md b/docs/work-items/ORCH-109/01-brd.md new file mode 100644 index 0000000..ad4dfb7 --- /dev/null +++ b/docs/work-items/ORCH-109/01-brd.md @@ -0,0 +1,167 @@ +--- +work_item: ORCH-109 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-14 +model_used: claude-opus-4-8 +--- + +# 01 — BRD (бизнес-требования): ORCH-109 — timeout budgets + launch-time model telemetry для developer/reviewer + +Work Item: **ORCH-109** · Repo: **orchestrator** · Стадия: analysis + +## 1. Бизнес-контекст и проблема + +Инцидент **ORCH-104** (runs 658/659/660, прод-watchdog 1800s) вскрыл **два независимых дефекта** +в подсистеме запуска агентов и телеметрии: + +**Дефект A — недостаточный wall-clock бюджет для тяжёлых ролей.** +Агенты `developer` и `reviewer` на сложных задачах **честно** упираются в общий тайм-аут +`agent_timeout_seconds = 1800` и убиваются watchdog'ом (`launcher._watchdog → stop_process`, +exit 143 / -9). Этот тайм-аут — единый для ВСЕХ ролей, хотя `developer` (effort `xhigh`, +кодирующая роль) и `reviewer` объективно требуют больше времени, чем механические роли +(`tester`/`deployer`, effort `medium`). Существует механизм per-agent override +(`_resolve_timeout` + `agent_timeout_overrides_json`), но в проде он пуст → все роли получают 1800s. + +**Дефект B — потеря модели в телеметрии при оборванном прогоне.** +Модель агента (`agent_runs.model`) пишется **только постфактум** — из финального usage-JSON +прогона в `launcher._monitor_agent → usage.record_usage` (`_extract_model`). Убитый по тайм-ауту +прогон **не успевает эмитить финальный JSON** → `_extract_model` возвращает `None` → +`record_usage` пишет `model=COALESCE(None, model)` = остаётся **NULL**. В результате карточка +Telegram-трекера (`notifications._stage_line`) и снимок `GET /metrics`/`GET /queue` +(`db.get_running_agents`) показывают `model=null` именно тогда, когда что-то пошло не так — в +момент, когда модель/эффорт критичны для разбора инцидента. + +Существующий прецедент уже решает половину задачи: **эффорт стампится в момент launch** +(`launcher._spawn`, ORCH-087, `UPDATE agent_runs SET effort=?`), потому что CLI его в result-JSON +не возвращает. Модель резолвится в той же точке (`resolve_agent_model`, строка 559), но **в БД на +launch не пишется** — стампится только эффорт. ORCH-109 распространяет ту же гарантию на модель. + +**Сопутствующие проверки (производные от A и B):** +- Поведение оборванного (timeout-killed) прогона в трекере и status-комментариях: модель и эффорт + должны быть видны даже если финальный JSON не записан. +- Нужен ли отдельный guard: не пускать timeout-killed `developer`/`reviewer` автоматически дальше + по конвейеру (`development → review`, `review → testing`) без явного salvage-режима. + +**Установленные факты (по коду, не изобретать):** +- `agent_runs.model` — колонка `TEXT` (NULLABLE), уже существует (`db._ensure_column`); **миграция + не нужна**. +- `record_usage` уже использует `model=COALESCE(?, model)` — то есть постфактум-парс уже + **сохраняет** ранее проставленное значение и не затирает его `NULL`'ом. Не хватает только + записи на launch. +- `_resolve_timeout(agent)` уже умеет per-agent override через `agent_timeout_overrides_json`; + малформный JSON → откат на глобальный дефолт + лог (never-break). +- Кросс-инвариант reaper: `reaper_max_running_s = 3600` с зафиксированным в `config.py` правилом + «MUST be > max agent_timeout + grace» (Tier-3 backstop job-reaper'а, ORCH-065). + +## 2. Объём (scope) + +### В объёме +- **Launch-time стамп модели:** записывать резолвенную `resolve_agent_model(...)` в + `agent_runs.model` в момент launch (`launcher._spawn`), рядом со стампом эффорта (ORCH-087). +- **Конфигурируемый поднятый wall-clock бюджет для `developer` и `reviewer`** через config-override, + **без изменения** бюджета остальных ролей (`analyst`/`architect`/`tester`/`deployer`). +- **Сохранение постфактум-enrich:** `usage.record_usage` остаётся источником обогащения + модели/токенов/стоимости из usage-JSON, но **перестаёт быть единственным источником истины** о + модели (launch-стамп — первичный, JSON — уточняющий). +- **Видимость при timeout/kill:** строка стадии трекера и status-комментарии показывают реальные + модель + эффорт для оборванного прогона (model не `null`). +- **Guard анти-salvage:** гарантия (и регресс-тест), что timeout-killed прогон + (`exit_code != 0`, в т.ч. -9/-15/143) **не продвигает** стадию автоматически в следующую без + явного решения. +- **Обновление документации/комментариев** по конфигу тайм-аутов (`config.py`, `.env.example`). +- **Тесты**, покрывающие все перечисленные FR. + +### Вне объёма +- Изменение model-routing: все 6 агентов остаются на `claude-opus-4-8` (ORCH-41 G3 не включается). +- Любые изменения `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключей / схемы БД + (колонка `agent_runs.model` уже есть — миграции нет). +- Изменение тайм-аута для ролей кроме `developer`/`reviewer`. +- **Salvage / возобновление** недоделанной работы убитого прогона (поднять «как было», дописать, + переиспользовать частичный результат) — в объёме ТОЛЬКО гарантия не-продвижения, не salvage. +- Изменения транспорта Telegram/Plane (`send_telegram`/комментарии) — только использование уже + доступных полей. +- Перезапуск/деплой прод-контейнера в рамках задачи (self-hosting безопасность). + +## 3. Заинтересованные стороны + +- **Заказчик/Owner (Слава)** — инициатор; нуждается в надёжной телеметрии для разбора инцидентов и + в адекватных бюджетах тяжёлых ролей при пакетном автономном прогоне (эпик ORCH-088). +- **Оператор self-hosting** — потребитель карточки трекера и `GET /metrics`/`GET /queue`; без модели + в карточке теряет ключевой контекст инцидента. +- **Сам конвейер (self-hosting)** — затрагивается поведение запуска агентов; общий прод-инстанс + обслуживает и enduro-trails (тайм-аут — глобальная per-agent настройка, не repo-scoped). + +## 4. Бизнес-требования (BR) + +- **BR-1** — Резолвенная модель агента сохраняется в `agent_runs.model` **в момент launch**, рядом + с эффортом, а не только постфактум из usage-JSON. Значение присутствует на строке прогона с + момента запуска и переживает любой исход прогона. +- **BR-2** — Постфактум-парс usage/model (`usage.record_usage`) сохраняется как **обогащение**, но + **не как единственный источник истины**: при отсутствии/обрыве финального JSON launch-стамп модели + не теряется. +- **BR-3** — Wall-clock тайм-аут для `developer` и `reviewer` поднимается и **настраивается через + config-override**, **без изменения** тайм-аута остальных ролей; механизм покрыт тестом/проверкой. +- **BR-4** — При timeout/kill (оборванный прогон без финального JSON) строка стадии в трекере и + status-комментарии показывают **реальную модель (не `null`) и эффорт**. +- **BR-5** — Timeout-killed прогон `developer`/`reviewer` **не продвигается** автоматически на + следующую стадию без явного salvage-режима; поведение зафиксировано регресс-тестом. (Анализ + определяет, нужен ли отдельный guard поверх существующей гарантии «advance только при чистом + exit + зелёный QG».) +- **BR-6** — Документация и комментарии по конфигу тайм-аутов обновлены (паспорт изменения внутри + `config.py` + `.env.example`). + +## 5. Нефункциональные требования (NFR) + +- **NFR-1 — Обратная совместимость / нулевая регрессия.** Стамп модели аддитивен (колонка уже + существует, миграции нет). Дефолтный тайм-аут ролей, кроме `developer`/`reviewer`, не меняется; + при пустом override-конфиге поведение байт-в-байт прежнее. +- **NFR-2 — never-raise / never-break.** Сбой стампа модели (ошибка БД) **не блокирует** launch + (та же `try/except`-изоляция, что у стампа эффорта). Малформный/невалидный timeout-конфиг → + откат на глобальный дефолт + WARNING, прогон не падает. +- **NFR-3 — Неприкосновенность контрактов.** `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, + machine-verdict ключи (`verdict:`/`result:`/`deploy_status:`/`staging_status:`/`security_status:`/ + `coverage_status:`), схема БД — **не трогаются**. +- **NFR-4 — Сохранение reaper-инварианта.** Любой поднятый бюджет `developer`/`reviewer` обязан + сохранять `reaper_max_running_s > max(резолвенный тайм-аут любого агента) + agent_kill_grace_seconds` + (Tier-3 backstop ORCH-065); иначе job-reaper может реапнуть **здоровый** долгоиграющий прогон до + срабатывания его собственного watchdog'а. Если новый бюджет нарушает неравенство — + `reaper_max_running_s` поднимается синхронно (решение архитектора). +- **NFR-5 — Self-hosting безопасность.** Изменение не рестартит/не роняет прод-контейнер, не + трогает deploy-путь, безопасно для общего инстанса (enduro-trails не затронут негативно). +- **NFR-6 — Наблюдаемость in-flight.** Модель становится видна в `GET /metrics`/`GET /queue` + (`db.get_running_agents`) **во время** прогона, а не только после завершения (побочное улучшение + launch-стампа). + +## 6. Допущения и ограничения + +- Тайм-аут — **глобальная per-agent** настройка (не repo-scoped): поднятие бюджета + `developer`/`reviewer` действует на все репо. Для enduro это благоприятно/нейтрально. +- Колонка `agent_runs.model` уже существует и NULLABLE — повторная запись/COALESCE безопасны. +- CLI не возвращает effort в result-JSON (причина launch-стампа эффорта ORCH-087); модель в JSON + возвращается, но только при успешном финале — отсюда необходимость launch-стампа модели. +- Точные числовые значения новых бюджетов (`developer`/`reviewer`) и способ их конфигурации + (выделенные ключи vs `agent_timeout_overrides_json`) — решение архитектора/Owner в рамках FR-3; + BRD фиксирует только **способность + инвариант NFR-4 + тест**. +- Salvage недоделанной работы — отдельная возможность, вне этой задачи. + +## 7. Критерии успеха + +Модель агента видна (не `null`) в трекере, status-комментариях и `/metrics` для ЛЮБОГО исхода +прогона, включая timeout-kill; бюджеты `developer`/`reviewer` подняты и конфигурируемы без влияния +на прочие роли и без нарушения reaper-инварианта; timeout-killed прогон не «протекает» в следующую +стадию; всё покрыто тестами; конфиг задокументирован. Детальные PASS/FAIL — `03-acceptance-criteria.md`. + +## 8. Риски + +- **R-1** — Поднятие бюджета выше `reaper_max_running_s − grace` → ложный reap здорового прогона + (NFR-4). Митигируется sanity-тестом конфига и/или синхронным поднятием `reaper_max_running_s`. +- **R-2** — Постфактум-enrich затирает корректный launch-стамп при странном JSON. Митигируется + семантикой COALESCE (NULL не затирает) + тестом enrich-кейсов. +- **R-3** — Гонка двух писателей `exit_code` (`_record_kill` = -9 и `_monitor_agent` = `proc.wait()`) + не должна влиять на телеметрию модели (модель — отдельная колонка). Подтверждается тестом FR-4. +- **R-4** — Глобальность тайм-аута: поднятие для enduro-developer могло бы маскировать зависший + прогон. Митигируется тем, что Tier-3 backstop reaper'а сохраняется (NFR-4). + +Детали рисков и архитектурные трейд-оффы — `10-tech-risks.md` (заполняет архитектор). diff --git a/docs/work-items/ORCH-109/02-trz.md b/docs/work-items/ORCH-109/02-trz.md new file mode 100644 index 0000000..23fff81 --- /dev/null +++ b/docs/work-items/ORCH-109/02-trz.md @@ -0,0 +1,145 @@ +--- +work_item: ORCH-109 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-14 +model_used: claude-opus-4-8 +--- + +# 02 — ТЗ (TRZ): ORCH-109 — timeout budgets + launch-time model telemetry для developer/reviewer + +Work Item: **ORCH-109** · Repo: **orchestrator** · Стадия: analysis + +> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода. +> Архитектурное обоснование/решения (выбор «выделенные config-ключи vs `agent_timeout_overrides_json`», +> точные числовые бюджеты, синхронная правка `reaper_max_running_s`) — задача архитектора (`06-adr`). + +## 1. Сводка изменения + +Две независимые, но связанные правки в подсистеме запуска агентов: + +1. **Launch-time стамп модели.** В `launcher._spawn` резолвенная `resolve_agent_model(...)` (уже + вычисляется на launch, строка ~559) записывается в `agent_runs.model` в той же DB-сессии, что и + стамп эффорта (ORCH-087, строки ~566–571). Постфактум-парс (`usage.record_usage`, + `model=COALESCE(?, model)`) сохраняется как **обогащение** и уже не затирает launch-значение + `NULL`'ом. Следствие: модель присутствует на строке прогона с момента запуска, переживает + timeout-kill и видна in-flight в `GET /metrics`/`GET /queue`. + +2. **Конфигурируемый поднятый wall-clock бюджет для `developer`/`reviewer`.** `_resolve_timeout(agent)` + должен возвращать поднятый бюджет для `developer` и `reviewer`, конфигурируемый и не затрагивающий + прочие роли; механизм покрыт тестом. Сохраняется never-break (малформный конфиг → глобальный + дефолт) и кросс-инвариант reaper (`reaper_max_running_s > max(timeout)+grace`). + +Плюс верификационные требования: телеметрия timeout-killed прогона (модель+эффорт не `null`) и +guard анти-salvage (timeout-killed прогон не продвигает стадию). + +## 2. Задействованные модули / пути + +| Путь | Действие | +|------|----------| +| `src/agents/launcher.py` | изменить — стамп `model` в `_spawn` рядом с `effort` (≈ стр. 559–573); проверка `_resolve_timeout` обслуживает override `developer`/`reviewer` (≈ стр. 661–679) | +| `src/config.py` | изменить — config для поднятого тайм-аута `developer`/`reviewer` (выделенные ключи и/или дефолт `agent_timeout_overrides_json`); обновить комментарии-паспорт (≈ стр. 115–126); проверить/при необходимости поднять `reaper_max_running_s` (≈ стр. 494–499) | +| `src/usage.py` | проверить/зафиксировать тестом — `record_usage` (`model=COALESCE(?, model)`) НЕ затирает launch-стамп при `model=None` (≈ стр. 207–230); `_extract_model` (≈ стр. 95–118) | +| `src/notifications.py` | проверить (правка, вероятно, не нужна) — `_stage_line` рендерит `· {model} · {effort}` из `agent_runs` для строки с `exit_code=-9` (≈ стр. 360–373, 498–542) | +| `src/db.py` | НЕ менять схему — `agent_runs.model` TEXT уже есть; проверить, что `get_running_agents` (≈ стр. 1370–1405) отдаёт launch-стампнутую модель для running-job | +| `src/stage_engine.py` | проверить — путь продвижения стадии не advance'ит прогон с `exit_code != 0` (guard FR-5); правка только если найден разрыв | +| `.env.example` | обновить — задокументировать ключи тайм-аута `developer`/`reviewer` (BR-6) | +| `tests/test_orch109_timeout_model.py` (новый) | создать — покрытие FR-1…FR-5 | +| `CHANGELOG.md`, `CLAUDE.md` (паспорт), `docs/architecture/README.md` (модель/эффорт-секция) | обновить в том же PR (правило агентов №2) | + +## 3. Функциональные требования + +### FR-1 — Launch-time стамп модели (BR-1) +В `launcher._spawn`, после `model = resolve_agent_model(agent, project_id)`, резолвенное значение +записывается в `agent_runs.model` для текущего `run_id` **в момент launch**, по образцу стампа +эффорта (ORCH-087): +- Запись в той же открытой `conn`, что и стамп эффорта (допустимо объединить в один + `UPDATE agent_runs SET model=?, effort=? WHERE id=?` — решение реализации). +- Пустой резолв (`model == ""`, CLI-дефолт без `--model`) → пишется `NULL` (как эффорт: `effort or None`), + чтобы суффикс модели в трекере корректно опускался. +- **Инвариант:** значение `agent_runs.model` присутствует с момента launch и не зависит от исхода + прогона. +- **never-raise (NFR-2):** сбой записи изолирован `try/except` + WARNING; launch продолжается. + +### FR-2 — Постфактум-enrich сохраняет launch-стамп (BR-2) +`usage.record_usage` остаётся источником обогащения (токены/стоимость/модель из usage-JSON), но: +- При `usage is None` или `usage.get("model") is None` (оборванный/малформный JSON) launch-стамп + модели **не затирается** (текущая семантика `model=COALESCE(?, model)` это уже обеспечивает — + требование зафиксировать тестом, не регрессировать). +- При наличии непустой модели в JSON enrich **уточняет** значение (например, полный + provider-prefixed id или фактический fallback-model) — допустимая перезапись непустым на непустое. +- Семантика парсинга `_extract_model` (приоритет `modelUsage` → top-level `model`) — без изменений. + +### FR-3 — Конфигурируемый поднятый тайм-аут `developer`/`reviewer` (BR-3) +- `_resolve_timeout(agent)` возвращает поднятый бюджет для `agent in {"developer","reviewer"}`, + конфигурируемый, **детерминированный**, и **не затрагивающий** прочие роли (они продолжают + получать глобальный `agent_timeout_seconds`, если для них нет override). +- Механизм: либо документированный дефолт `agent_timeout_overrides_json`, либо выделенные ключи + (например `agent_timeout_developer_s`/`agent_timeout_reviewer_s`) — выбор архитектора; контракт + FR-3 — резолв per-agent поднятого бюджета. +- **never-break (NFR-2):** малформный/невалидный конфиг → откат на глобальный дефолт + WARNING + (поведение `_resolve_timeout` сохраняется). +- **Кросс-инвариант (NFR-4):** итоговый `max(резолвенный тайм-аут)` + `agent_kill_grace_seconds` + обязан оставаться `< reaper_max_running_s`; при нарушении — синхронно поднять `reaper_max_running_s`. + +### FR-4 — Телеметрия timeout-killed прогона (BR-4) +Для прогона с `exit_code != 0` без финального usage-JSON (timeout-kill, `_record_kill` стампит -9): +- Строка стадии трекера (`notifications._stage_line`) рендерит `· {short_model} · {effort}` с + реальными значениями (модель **не** `null`), т.к. оба стампнуты на launch (FR-1 + ORCH-087). +- `db.get_running_agents` (источник `GET /metrics`/`GET /queue`) отдаёт launch-стампнутую модель и + для **running**-job (in-flight видимость, NFR-6). +- Изменения `notifications.py`, вероятно, не требуются (рендер уже читает `model`); требование — + верифицировать тестом, что при стампе на launch значение долетает. + +### FR-5 — Guard анти-salvage timeout-killed прогона (BR-5) +- Timeout-killed прогон (`exit_code != 0`, в т.ч. -9/-15/143) `developer`/`reviewer` **не продвигает** + стадию (`development → review`, `review → testing`) автоматически. +- Существующий контракт (advance только при чистом exit-коде + зелёный exit-гейт; иначе + `attempts` возвращает `resolve_agent_model(agent)` (непустую + модель для текущей конфигурации — `claude-opus-4-8`); при пустом резолве — `NULL`. Запись + происходит рядом со стампом эффорта (`launcher._spawn`). +- **FAIL:** модель пишется только в `usage.record_usage` (постфактум); строка прогона имеет + `model IS NULL` до завершения; стамп не изолирован и роняет launch при ошибке БД. + +--- + +## AC-2 — Постфактум-enrich не затирает launch-стамп при оборванном JSON + +**Условие:** `usage.record_usage` с отсутствующей/`None`-моделью не обнуляет launch-стампнутую модель. +- **PASS:** `record_usage(run_id, None)` и `record_usage(run_id, {... "model": None})` для строки с + launch-стампнутой моделью → `model` остаётся прежним непустым (семантика `COALESCE(?, model)`); + `record_usage(run_id, {... "model": "claude-opus-4-8"})` → модель проставлена/уточнена. +- **FAIL:** оборванный/малформный JSON приводит к `model = NULL`; enrich затирает корректный + launch-стамп. + +--- + +## AC-3 — Тайм-аут `developer`/`reviewer` поднят и конфигурируем без влияния на прочие роли + +**Условие:** `launcher._resolve_timeout(agent)` возвращает поднятый бюджет для `developer`/`reviewer` +и неизменный глобальный дефолт для остальных. +- **PASS:** при сконфигурированном override `_resolve_timeout("developer")` и + `_resolve_timeout("reviewer")` возвращают поднятые значения; `_resolve_timeout("analyst")`, + `("architect")`, `("tester")`, `("deployer")` возвращают `settings.agent_timeout_seconds` (1800 по + умолчанию). Конфигурация описана в `config.py` и `.env.example`. +- **FAIL:** изменён бюджет роли вне `{developer, reviewer}`; значение захардкожено; бюджет не + настраивается через config. + +--- + +## AC-4 — Малформный timeout-конфиг → безопасный откат (never-break) + +**Условие:** невалидный/малформный конфиг тайм-аутов не роняет прогон и не ломает старт. +- **PASS:** при малформном `agent_timeout_overrides_json` (или невалидном выделенном ключе) + `_resolve_timeout(...)` возвращает глобальный дефолт + пишет WARNING; процесс не падает. +- **FAIL:** исключение пробрасывается; прогон/старт падает на плохом env. + +--- + +## AC-5 — Reaper-инвариант сохранён + +**Условие:** `reaper_max_running_s > max(резолвенный тайм-аут любого агента) + agent_kill_grace_seconds`. +- **PASS:** с применённой конфигурацией бюджетов sanity-тест подтверждает неравенство для всех ролей + (`developer`/`reviewer` включительно); при необходимости `reaper_max_running_s` поднят синхронно. +- **FAIL:** поднятый бюджет `developer`/`reviewer` + grace ≥ `reaper_max_running_s` → job-reaper может + реапнуть здоровый долгий прогон. + +--- + +## AC-6 — Строка стадии трекера показывает модель+эффорт при timeout/kill + +**Условие:** для прогона с `exit_code = -9` (timeout-kill) с launch-стампнутыми model+effort строка +стадии рендерит оба значения. +- **PASS:** `notifications`-рендер строки стадии (`_stage_line`) для такого `agent_runs`-ряда содержит + ` · · ` (например `· opus-4-8 · xhigh`); модель **не** `null`/пустая. +- **FAIL:** при `exit_code=-9` строка показывает стоимость без модели (суффикс модели опущен), т.к. + `model IS NULL`. + +--- + +## AC-7 — In-flight видимость модели в `/metrics` и `/queue` + +**Условие:** `db.get_running_agents` отдаёт модель для **running** job'а (до завершения прогона). +- **PASS:** для running-job с launch-стампнутой моделью `get_running_agents()[i]["model"]` непуст; + `GET /metrics` `agents[].model` непуст для активного агента. +- **FAIL:** `model` остаётся `null` для running-job до завершения прогона. + +--- + +## AC-8 — Timeout-killed прогон не продвигает стадию (анти-salvage) + +**Условие:** прогон `developer`/`reviewer` с `exit_code != 0` (timeout-kill) не вызывает переход +`development → review` / `review → testing`. +- **PASS:** регресс-тест подтверждает, что прогон с `exit_code = -9` не продвигает стадию + автоматически (следует retry/fail-пути; advance — только при чистом exit + зелёный exit-гейт). + Salvage-режим отсутствует. +- **FAIL:** убитый по тайм-ауту прогон «протекает» в следующую стадию без явного решения; либо введён + неявный auto-salvage. + +--- + +## AC-9 — Неприкосновенность контрактов и схемы + +**Условие:** задача не трогает машину стадий, гейты и схему БД. +- **PASS:** диффы НЕ содержат изменений `STAGE_TRANSITIONS`, реестра `QG_CHECKS`, `check_*`/`_parse_*`, + machine-verdict ключей, `CREATE TABLE`/`ALTER TABLE`. `agent_runs.model` используется как есть. +- **FAIL:** любое из перечисленного изменено. + +--- + +## AC-10 — Документация и регресс + +**Условие:** конфиг задокументирован, полный регресс зелёный. +- **PASS:** комментарий-паспорт в `config.py` (блок ORCH-7) и `.env.example` описывают бюджеты + `developer`/`reviewer`; `CHANGELOG.md`/`CLAUDE.md`/`docs/architecture/README.md` обновлены в том же + PR; `pytest tests/ -q` зелёный; новые тесты ORCH-109 проходят. +- **FAIL:** конфиг не задокументирован; документация рассинхронизирована с кодом; регресс красный. + +--- + +## Сводная матрица AC ↔ FR/BR + +| AC | Покрывает | +|----|-----------| +| AC-1 | BR-1 / FR-1 | +| AC-2 | BR-2 / FR-2 | +| AC-3 | BR-3 / FR-3 | +| AC-4 | BR-3 / FR-3 / NFR-2 | +| AC-5 | NFR-4 / FR-3 | +| AC-6 | BR-4 / FR-4 | +| AC-7 | BR-4 / FR-4 / NFR-6 | +| AC-8 | BR-5 / FR-5 | +| AC-9 | NFR-1 / NFR-3 / FR-5 | +| AC-10 | BR-6 / FR-6 / NFR-1 | diff --git a/docs/work-items/ORCH-109/04-test-plan.yaml b/docs/work-items/ORCH-109/04-test-plan.yaml new file mode 100644 index 0000000..d9ee3ec --- /dev/null +++ b/docs/work-items/ORCH-109/04-test-plan.yaml @@ -0,0 +1,94 @@ +work_item: ORCH-109 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-14 +model_used: claude-opus-4-8 +title: "Timeout budgets + launch-time model telemetry для developer/reviewer" +framework: pytest +scope: > + Покрывает: launch-time стамп модели в agent_runs.model (FR-1), сохранение launch-стампа + постфактум-enrich'ем (FR-2), конфигурируемый поднятый тайм-аут developer/reviewer без влияния + на прочие роли (FR-3) + never-break на малформном конфиге, reaper-инвариант (NFR-4), видимость + модели+эффорта в строке трекера при timeout-kill (FR-4) и in-flight в get_running_agents (NFR-6), + guard анти-salvage — timeout-killed прогон не продвигает стадию (FR-5). Вне покрытия: model-routing, + salvage недоделанной работы, изменения STAGE_TRANSITIONS/QG_CHECKS/схемы (их и не должно быть). +notes: > + Тесты детерминированы, без сети/LLM/subprocess Claude CLI: используют временную SQLite-БД и + синтетические agent_runs-ряды; настройки подменяются через monkeypatch/override settings. + Полный регресс tests/ должен оставаться зелёным; новый файл tests/test_orch109_timeout_model.py. + Любой найденный разрыв в FR-5 закрывается guard'ом + тестом; если разрыва нет — TC-08 фиксирует + существующую гарантию как анти-регресс. + +tests: + - id: TC-01 + type: unit + description: "_resolve_timeout('developer') и ('reviewer') возвращают поднятый сконфигурированный бюджет" + module: tests/test_orch109_timeout_model.py + expected: PASS + + - id: TC-02 + type: unit + description: "_resolve_timeout для analyst/architect/tester/deployer возвращает глобальный agent_timeout_seconds (1800) — прочие роли не затронуты" + module: tests/test_orch109_timeout_model.py + expected: PASS + + - id: TC-03 + type: unit + description: "Малформный/невалидный timeout-конфиг -> _resolve_timeout откатывается на глобальный дефолт + WARNING, без исключения (never-break)" + module: tests/test_orch109_timeout_model.py + expected: PASS + + - id: TC-04 + type: integration + description: "Launch стампит agent_runs.model: после стамп-блока _spawn строка прогона имеет model == resolve_agent_model(agent) (непустую), рядом с effort" + module: tests/test_orch109_timeout_model.py + expected: PASS + + - id: TC-05 + type: unit + description: "Стамп модели изолирован: сбой записи (битый conn) не пробрасывает исключение из launch-пути (never-raise, NFR-2)" + module: tests/test_orch109_timeout_model.py + expected: PASS + + - id: TC-06 + type: unit + description: "record_usage(run_id, None) и record_usage с model=None НЕ затирают launch-стампнутую модель (COALESCE preserve, FR-2)" + module: tests/test_orch109_timeout_model.py + expected: PASS + + - id: TC-07 + type: unit + description: "record_usage с непустой model в usage-JSON уточняет/проставляет agent_runs.model (enrich по-прежнему работает)" + module: tests/test_orch109_timeout_model.py + expected: PASS + + - id: TC-08 + type: unit + description: "Sanity reaper-инварианта: reaper_max_running_s > max(резолвенный тайм-аут всех ролей) + agent_kill_grace_seconds (NFR-4)" + module: tests/test_orch109_timeout_model.py + expected: PASS + + - id: TC-09 + type: integration + description: "Строка стадии трекера (_stage_line) для agent_runs с exit_code=-9 и launch-стампнутыми model+effort рендерит ' · · ' (model не null)" + module: tests/test_orch109_timeout_model.py + expected: PASS + + - id: TC-10 + type: integration + description: "get_running_agents отдаёт непустую model для running-job с launch-стампнутой моделью (in-flight видимость /metrics /queue, NFR-6)" + module: tests/test_orch109_timeout_model.py + expected: PASS + + - id: TC-11 + type: integration + description: "Анти-salvage: прогон developer/reviewer с exit_code=-9 не продвигает стадию (development->review / review->testing) автоматически; следует retry/fail-пути" + module: tests/test_orch109_timeout_model.py + expected: PASS + + - id: TC-12 + type: integration + description: "Анти-регресс контрактов: STAGE_TRANSITIONS/QG_CHECKS/check_* и схема agent_runs не изменены (модель пишется в существующую колонку, миграции нет)" + module: tests/test_orch109_timeout_model.py + expected: PASS diff --git a/docs/work-items/ORCH-109/06-adr/ADR-001-agent-timeout-budgets-and-launch-model-stamp.md b/docs/work-items/ORCH-109/06-adr/ADR-001-agent-timeout-budgets-and-launch-model-stamp.md new file mode 100644 index 0000000..c1a10c5 --- /dev/null +++ b/docs/work-items/ORCH-109/06-adr/ADR-001-agent-timeout-budgets-and-launch-model-stamp.md @@ -0,0 +1,221 @@ +--- +work_item: ORCH-109 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-14 +model_used: claude-opus-4-8 +--- + +# ADR-001: Поднятые wall-clock бюджеты developer/reviewer + launch-time стамп модели + +Work Item: **ORCH-109** — timeout budgets + launch-time model telemetry для developer/reviewer +Стадия: **architecture** +Сквозная регистрация: **`docs/architecture/adr/adr-0040-agent-timeout-budgets-and-launch-model-stamp.md`** +(решение кросс-каттинговое: меняет два глобальных per-agent инварианта подсистемы запуска — +бюджеты тайм-аутов всех репо и потолок Tier-3 reaper'а ORCH-065). + +## Статус +Proposed + +## Контекст + +Инцидент **ORCH-104** (runs 658/659/660) вскрыл два независимых дефекта подсистемы запуска агентов +(`src/agents/launcher.py`), верифицированных по коду: + +- **Дефект A — единый тайм-аут для всех ролей.** `_resolve_timeout(agent)` (launcher.py ≈661–679) + возвращает `settings.agent_timeout_seconds = 1800` (config.py:124) для **всех** ролей, если в + `agent_timeout_overrides_json` нет записи (в проде он пуст: `""`, config.py:126). Тяжёлые роли + `developer` (effort `xhigh`, кодирующая) и `reviewer` (effort `high`, читает диф + пишет ревью) + **честно** упираются в 1800s и убиваются watchdog'ом (`_watchdog → stop_process`, exit_code=-9 + через `_record_kill`, launcher.py:778–786). Механические роли (`tester`/`deployer`, effort + `medium`) в этот бюджет укладываются. +- **Дефект B — потеря модели в телеметрии при обрыве.** `agent_runs.model` пишется только + постфактум — из финального usage-JSON в `usage.record_usage` (`model=COALESCE(?, model)`, + usage.py:217). Убитый по тайм-ауту прогон не успевает эмитить финальный JSON → `_extract_model` + даёт `None` → модель остаётся `NULL` ровно тогда, когда она критична для разбора инцидента. + При этом **эффорт уже стампится на launch** (ORCH-087, launcher.py:566–571, `UPDATE agent_runs + SET effort=? WHERE id=?`), потому что CLI его в result-JSON не отдаёт; модель в той же точке + **резолвится** (`model = resolve_agent_model(...)`, launcher.py:559), но в БД на launch **не + пишется**. + +Установленные факты (по коду, не изобретены): +- Колонка `agent_runs.model TEXT` (NULLABLE) уже существует (`db.py:111`, `_ensure_column`) — + **миграции нет**. +- `record_usage` уже использует `model=COALESCE(?, model)` → `None` не затирает ранее проставленное + значение (usage.py:217). Не хватает только записи на launch. +- `db.get_running_agents()` уже отдаёт `r.model AS model` (`db.py` ≈1370–1405) — running-job увидит + модель **сразу** после launch-стампа, без правки SELECT. +- `notifications._stage_line` рендерит `· {model} · {effort}` из строки `agent_runs` — увидит + launch-стампнутую модель даже для `exit_code=-9`, без правки. +- Продвижение стадии гейтится `if exit_code == 0: self._try_advance_stage(...)` (launcher.py:951–952); + иначе → `_finalize_job` (launcher.py:957) → retry/fail. Timeout-kill (-9) **структурно** не + продвигает стадию. +- Кросс-инвариант reaper (ORCH-065): `reaper_max_running_s = 3600` (config.py:497) c зафиксированным + правилом «MUST be > max agent_timeout + grace» (config.py:480–482; `job_reaper.py:43,228`). + Сейчас `3600 > 1800 + 20 = 1820` ✓. **Любое поднятие бюджета обязано пересчитать это неравенство.** +- Sidecar-watchdog (`watchdog/`, ORCH-100) — **наблюдатель**, процессы **не убивает**; сигнал + `agent_hung` (runtime > `agent_hung_min`=20м **И** cpu < 1%) — только Telegram-алерт. Кому + принадлежит kill — исключительно in-process `launcher._watchdog`. + +Почему «как есть» не годится: единый бюджет 1800 системно убивает здоровые тяжёлые прогоны при +пакетном автономном прогоне (эпик ORCH-088), а телеметрия теряет модель именно на этих обрывах. + +## Решение + +### Сводка +Две аддитивные, изолированные правки подсистемы запуска, **без** касания +`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схемы БД: +(1) стамп резолвенной модели в `agent_runs.model` **в момент launch** рядом со стампом эффорта; +(2) **выделенные типизированные config-ключи** поднятого wall-clock бюджета для `developer`/`reviewer` +с синхронным поднятием `reaper_max_running_s` (сохранение инварианта ORCH-065). +FR-5 (анти-salvage) и FR-4/NFR-6 (видимость при kill / in-flight) — **структурно уже выполнены** +существующим кодом; ORCH-109 добавляет к ним регресс-тесты, а не новые ветви. + +### D1 — Launch-time стамп модели (FR-1, AC-1) +В `launcher._spawn`, в той же открытой `conn`, что и стамп эффорта (ORCH-087), резолвенная +`model = resolve_agent_model(agent, project_id)` (уже вычислена, launcher.py:559) записывается в +`agent_runs.model` текущего `run_id`. Рекомендуется **объединить** в один оператор: +`UPDATE agent_runs SET model=?, effort=? WHERE id=?` с параметрами `(model or None, effort or None, run_id)` +(один commit вместо двух; ровно та же `try/except`-изоляция, что у эффорта). +- Пустой резолв (`model == ""`, CLI-дефолт без `--model`) → пишется `NULL` (симметрично `effort or None`) + → суффикс модели в трекере корректно опускается. +- **Инвариант:** значение присутствует с момента launch и не зависит от исхода прогона (переживает + timeout-kill, виден in-flight). +- **never-raise (NFR-2):** сбой записи изолирован существующим `try/except` + WARNING; launch + продолжается (`model_flag` строится из локальной `model`, а не из БД — стамп лишь телеметрия). + +### D2 — Постфактум-enrich сохраняет launch-стамп (FR-2, AC-2) — без кода +`usage.record_usage` остаётся источником обогащения (токены/стоимость/модель из usage-JSON), но +**перестаёт быть единственным источником истины** о модели. Семантика `model=COALESCE(?, model)` +(usage.py:217) **уже** гарантирует: `usage=None` или `usage["model"]=None` → launch-стамп НЕ +затирается; непустая модель из JSON — допустимо уточняет (полный provider-prefixed id / фактический +fallback). **Код не меняется**; требование — зафиксировать поведение тестом (анти-регресс), не +сломать его будущими правками `record_usage`. + +### D3 — Конфигурируемый поднятый бюджет: выделенные типизированные ключи (FR-3, AC-3/AC-4) +Вводятся два **выделенных** config-ключа (по образцу `agent_model_`/`agent_effort_`, +config.py:133–138/147): + +```python +agent_timeout_developer_s: int = 3600 # env ORCH_AGENT_TIMEOUT_DEVELOPER_S +agent_timeout_reviewer_s: int = 3000 # env ORCH_AGENT_TIMEOUT_REVIEWER_S +``` + +`_resolve_timeout(agent)` получает детерминированную лестницу приоритетов (от высшего): +1. **`agent_timeout_overrides_json[agent]`** — существующий операторский escape-hatch; сохраняется + как высший приоритет (полная BC: сконфигурированный JSON по-прежнему выигрывает для ЛЮБОЙ роли). +2. **выделенный ключ роли** — `developer → agent_timeout_developer_s`, + `reviewer → agent_timeout_reviewer_s`. +3. **`settings.agent_timeout_seconds`** (1800) — для всех прочих ролей (`analyst`/`architect`/ + `tester`/`deployer`) — **байт-в-байт прежнее значение**. + +**never-break (NFR-2, AC-4):** малформный `agent_timeout_overrides_json` → уже игнорируется + WARNING +(launcher.py:677–678). Для выделенных ключей добавляется такой же защитный гард: если резолвенное +значение не положительный int (абсурд/0/отрицательное) → откат на `agent_timeout_seconds` + WARNING +(зеркало защитной валидации disk_monitor, ORCH-063 D7). Прогон/старт не падает. + +**Почему выделенные ключи, а не дефолт `agent_timeout_overrides_json`:** см. «Альтернативы». + +### D4 — Числовые бюджеты + синхронное поднятие reaper (FR-3/NFR-4, AC-5) +| Роль | Бюджет | Обоснование | +|------|--------|-------------| +| `developer` | **3600s (60м)** | бутылочное горло (xhigh, кодирующая); удвоение 1800→3600 — естественная разрядка для тяжёлых задач | +| `reviewer` | **3000s (50м)** | асимметрично легче developer, но тяжелее механических ролей; большой диф + high-reasoning | +| прочие | 1800s (без изменений) | механические/думающие роли укладываются в дефолт | + +`reaper_max_running_s`: **3600 → 5400 (90м)** синхронно (config.py:497). + +**Проверка инварианта ORCH-065** `reaper_max_running_s > max(резолвенный тайм-аут) + agent_kill_grace_seconds`: +`5400 > 3600 + 20 = 3620` ✓ (запас **1780s** — покрывает и окно финализации монитора: +commit/push/PR/usage-comments, Tier-2 `reaper_finalize_grace_s`=300). Дополнительно `5400 < ` +sidecar `stage_stuck_s` (7200s/120м) → легитимный длинный developer-прогон не порождает ложный +`stage_stuck`-алерт. + +Бюджеты — **глобальные per-agent** (не repo-scoped): действуют на все репо, включая enduro-trails. +Это благоприятно/нейтрально (enduro-developer тоже получает воздух; Tier-3 backstop reaper'а +сохраняется как страховка от реально зависшего прогона — R-4). + +### D5 — FR-5 анти-salvage: регресс-тест, без нового кода (AC-8) +Гарантия «timeout-killed прогон не продвигает стадию» **структурна**: `_try_advance_stage` вызывается +только под `if exit_code == 0` (launcher.py:951–952); kill (-9/-15/143) → `_finalize_job` → +`_finalize_transient`/`_finalize_permanent` (retry до `MAX_DEVELOPER_RETRIES`, иначе `failed` + +Telegram). **Новый guard в коде НЕ вводится** (не плодить лишние ветви риска) — добавляется +регресс-тест, фиксирующий, что прогон с `exit_code=-9` не вызывает `advance_stage`. salvage-режим +вне объёма. + +### D6 — Документация и канон дефолтов (FR-6, AC-10) +- `config.py` блок ORCH-7 (≈115–126): паспорт-комментарий расширяется описанием выделенных бюджетов + developer/reviewer + явной ссылкой на reaper-инвариант (NFR-4) с числами `5400 > 3620`. +- `.env.example`: **сейчас агент-тайм-аут ключей нет вовсе** (`ORCH_AGENT_TIMEOUT_SECONDS`/ + `_KILL_GRACE_SECONDS`/`_OVERRIDES_JSON` отсутствуют) → добавляется новый блок «Agent timeout + (ORCH-7/ORCH-109)» с пятью ключами (`SECONDS`/`KILL_GRACE_SECONDS`/`OVERRIDES_JSON`/ + `DEVELOPER_S`/`REVIEWER_S`) **+ обновляется `ORCH_REAPER_MAX_RUNNING_S=3600 → 5400`** (line 377). + Дефолты = боевым значениям (канон ORCH-101): пустой `.env` воспроизводит прод-поведение, в т.ч. + поднятые бюджеты. +- Архитектурная golden source (этот PR, авторство architect): `docs/architecture/README.md` + (бюллет Agent Launcher), `docs/architecture/internals.md` (стр. 96/262 — «timeout 30 мин» + расхардкоживается в per-role). Паспорт `CLAUDE.md` + `CHANGELOG.md` — обновляет developer в том + же PR (правило агентов №2). + +### Согласование BR-3 ↔ NFR-1 (важный нюанс) +NFR-1 требует «при пустом override-конфиге поведение байт-в-байт прежнее», а BR-3 требует «бюджеты +developer/reviewer подняты». Разрешение по канону **ORCH-101** («дефолт каждого параметра = боевому +значению; пустой `.env` ⇒ боевое поведение»): выделенные ключи **дефолтят на поднятый прод-бюджет**, +поэтому пустой `.env` даёт уже исправленное (поднятое) поведение для developer/reviewer — это и есть +намеренная боевая конфигурация. «Байт-в-байт прежнее» строго применяется к **прочим ролям** +(`analyst`/`architect`/`tester`/`deployer` остаются на 1800) — что и есть суть BR-3 (поднять ТОЛЬКО +две роли). Зафиксировано явно, чтобы reviewer не прочитал поднятый дефолт как нарушение NFR-1. + +## Альтернативы +- **Дефолт `agent_timeout_overrides_json = {"developer":3600,"reviewer":3000}`** (вместо выделенных + ключей) — отвергнуто: (1) ломает канон ORCH-101 «пустой = боевой» неочевидным непустым JSON-строковым + дефолтом; (2) JSON-строка хрупка (парс, экранирование) против типизированного int; (3) нельзя + переопределить одну роль одной env-переменной; (4) расходится с конвенцией per-agent скаляров + (`agent_model_`/`agent_effort_`). Выделенные ключи дают типобезопасность, индивидуальный + env-override и сохраняют JSON как чистый escape-hatch. +- **Бюджет developer/reviewer ≤ 3580 без поднятия reaper** (например 3000/2700) — отвергнуто как + доминирующее, но рассмотрено: держит `reaper_max_running_s=3600` нетронутым (меньший blast-radius), + но искусственно урезает самую тяжёлую роль ради статичности backstop-числа — оптимизация не той + переменной. NFR-4 **явно делегирует** архитектору синхронное поднятие reaper. Оставлено как + операторский запасной путь: всё env-override'имо, Owner может занизить бюджеты и вернуть reaper к + 3600 одной правкой `.env` (см. «Откат»). +- **Новый guard-leaf анти-salvage** (FR-5) — отвергнуто: продвижение уже гейтится exit-кодом + (launcher.py:951); новый код = лишняя ветвь риска. Достаточно регресс-теста (D5). +- **Repo-scoped бюджеты (`*_repos`)** — отвергнуто: тайм-аут — свойство launch, не гейт-решение; + глобальность благоприятна enduro и проще; гейт-паттерн `applies(repo)` тут неуместен. +- **Стамп модели через постфактум-парс лога на kill** — отвергнуто: модель известна на launch + детерминированно (`resolve_agent_model`); парсить оборванный лог — хрупко и поздно. + +## Последствия +- **+** Модель видна (не `null`) в трекере, status-комментариях, `/metrics`/`/queue` для **любого** + исхода, включая timeout-kill — ключевой контекст инцидента доступен в момент сбоя (BR-1/BR-4/NFR-6). +- **+** Тяжёлые роли получают реальный бюджет (developer ×2, reviewer +67%) → меньше ложных + timeout-kill на сложных задачах при автономном прогоне (ORCH-088). +- **+** Аддитивно/обратимо: ни схемы, ни гейтов, ни новых компонентов; `agent_runs.model` уже есть. +- **−** `reaper_max_running_s` 60→90м: реально зависший прогон (двойной отказ — watchdog-поток **и** + pid-liveness) держится Tier-3 backstop'ом на 30м дольше. Митигейшн: Tier-1 (pid) и Tier-2 + (finalize-grace) ловят типовые случаи быстрее; watchdog убивает в ≤3600s; double-fault редок. +- **−** Глобальность бюджета поднимает и enduro-роли. Митигейшн: Tier-3 reaper сохранён (R-4); + поднятие благоприятно для качества enduro-прогонов. +- **−** Sidecar `agent_hung_min`=20м теперь заметно ниже бюджета developer (60м) → возможны + Telegram-алерты `agent_hung` для здоровых длинных прогонов с low-CPU фазами. Митигейшн: сигнал — + **alert-only** (не убивает) и конъюнкция с `cpu<1%` гасит большинство ложных; тюнинг + `WATCHDOG_AGENT_HUNG_MIN` — вне объёма (отдельный sidecar-конфиг, alert-only). Детали — `10-tech-risks.md` TR-5. +- **Откат:** занизить бюджеты — снять/уменьшить `ORCH_AGENT_TIMEOUT_DEVELOPER_S`/`_REVIEWER_S` + (или выставить = 1800) и вернуть `ORCH_REAPER_MAX_RUNNING_S=3600`; launch-стамп модели отката не + требует (чистое улучшение телеметрии, COALESCE безопасен). Kill-switch не вводится — изменение не + добавляет рисковых ветвей (стамп всегда безопасен; тайм-аут fail-safe на глобальный дефолт). + +## Ссылки +- BRD: `docs/work-items/ORCH-109/01-brd.md` +- TRZ: `docs/work-items/ORCH-109/02-trz.md` +- Acceptance: `docs/work-items/ORCH-109/03-acceptance-criteria.md` +- Tech-risks: `docs/work-items/ORCH-109/10-tech-risks.md` +- Сквозной ADR: `docs/architecture/adr/adr-0040-agent-timeout-budgets-and-launch-model-stamp.md` +- Сверено по коду: `src/agents/launcher.py` (`_spawn` 559–571, `_resolve_timeout` 661–679, + `_watchdog`/`stop_process`/`_record_kill` 681–786, advance-гейт 951–952), `src/usage.py` + (`_extract_model` 95–118, `record_usage` 207–230), `src/config.py` (115–126, 480–497), + `src/db.py` (`agent_runs.model` 111, `get_running_agents` ≈1370–1405), `src/job_reaper.py` + (43, 228), `watchdog/config.py`/`watchdog/signals.py` (agent_hung/stage_stuck) +- Маркер-инвариант: ORCH-065 (reaper Tier-3), ORCH-087 (стамп эффорта), ORCH-101 (канон дефолтов) diff --git a/docs/work-items/ORCH-109/10-tech-risks.md b/docs/work-items/ORCH-109/10-tech-risks.md new file mode 100644 index 0000000..9f141c3 --- /dev/null +++ b/docs/work-items/ORCH-109/10-tech-risks.md @@ -0,0 +1,42 @@ +--- +work_item: ORCH-109 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-14 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-109 — timeout budgets + launch-time model telemetry + +Work Item: **ORCH-109** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Перечисляет риски реализации и их митигейшн. + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 | Поднятый бюджет developer/reviewer + grace ≥ `reaper_max_running_s` → job-reaper реапает **здоровый** долгий прогон до его watchdog'а (нарушение инварианта ORCH-065) | Низ. | Выс. | reaper синхронно поднят 3600→5400; sanity-тест проверяет `reaper_max_running_s > max(timeout)+grace` для всех ролей (`5400 > 3620`, запас 1780s); число живёт в `config.py` + `.env.example` рядом с инвариантом-комментарием (ADR D4/AC-5) | +| TR-2 | Постфактум-enrich (`record_usage`) затирает корректный launch-стамп при странном/оборванном JSON (`model=None`) | Низ. | Сред. | Семантика `model=COALESCE(?, model)` (usage.py:217) уже сохраняет launch-значение; зафиксировано регресс-тестом (AC-2); `record_usage` не правится | +| TR-3 | Гонка двух писателей `exit_code` (`_record_kill`=-9 и `_monitor_agent`=`proc.wait()`) искажает телеметрию модели | Низ. | Низ. | Модель — отдельная колонка, стампится один раз на launch до обоих писателей exit_code; они трогают только `exit_code`/`finished_at`. Подтверждается тестом (AC-1/AC-6) | +| TR-4 | Глобальность бюджета: поднятый developer-тайм-аут для **enduro** маскирует реально зависший прогон | Низ. | Сред. | Tier-3 backstop reaper'а (`reaper_max_running_s`) сохранён как абсолютный потолок; watchdog по-прежнему убивает в ≤ бюджета; бюджет лишь повышен, не снят | +| TR-5 | Sidecar `agent_hung_min`=20м заметно ниже бюджета developer (60м) → Telegram-алерты `agent_hung` для здоровых длинных прогонов | Сред. | Низ. | Сигнал **alert-only** (sidecar — наблюдатель, не убивает, ORCH-100); конъюнкция с `cpu<1%` гасит активный прогон; тюнинг `WATCHDOG_AGENT_HUNG_MIN` — вне объёма (отдельный sidecar-конфиг). Бюджет 5400s < `stage_stuck_s`=7200s → `stage_stuck` не ложит | +| TR-6 | Сбой записи launch-стампа модели (ошибка БД) роняет launch | Низ. | Выс. | Стамп в существующем `try/except` ORCH-087 + WARNING (never-raise, NFR-2); `model_flag` строится из локальной переменной, не из БД → launch не зависит от стампа (ADR D1) | +| TR-7 | Малформный/невалидный timeout-конфиг (битый JSON, нечисловой/отрицательный ключ) роняет прогон или старт | Низ. | Сред. | Малформный JSON → игнор + WARNING (существующее, launcher.py:677); выделенный ключ вне диапазона → откат на глобальный дефолт + WARNING (защитная валидация по образцу ORCH-063 D7); pydantic ловит нечисловой env на старте (AC-4) | +| TR-8 | Регресс прочих ролей: правка `_resolve_timeout` случайно меняет бюджет `analyst`/`architect`/`tester`/`deployer` | Низ. | Сред. | Лестница приоритетов: dev/reviewer — только по точному имени роли; прочие падают на `agent_timeout_seconds` (1800) без изменений; покрыто тестом per-role (AC-3) | +| TR-9 | Доп. риск контрактов: правка случайно задевает `STAGE_TRANSITIONS`/`QG_CHECKS`/machine-verdict/схему | Низ. | Выс. | Задача целиком вне слоя гейтов; диф-проверка AC-9; колонка `agent_runs.model` уже есть — ни одного `CREATE/ALTER` | + +## Сводный вывод + +Доминирующий класс — **конфигурационные инварианты подсистемы запуска** (TR-1/TR-7/TR-8): все +снимаются детерминированной лестницей `_resolve_timeout`, защитной валидацией (never-break) и +sanity-тестом reaper-неравенства. Остаточный риск для прод-конвейера (self-hosting) — **низкий**: +изменение аддитивно, обратимо через `.env`, не трогает гейты/схему/деплой-путь и не рестартит +прод-контейнер (NFR-5). Единственный наблюдаемый побочный эффект — возможный рост alert-only +`agent_hung`-нотификаций sidecar (TR-5), не влияющий на конвейер. + +**Эскалация:** не требуется на уровне `arch:major-change` (нет новой стадии/компонента/смены БД), но +решение **кросс-каттинговое** (меняет два глобальных per-agent инварианта всех репо + потолок Tier-3 +reaper'а) → зарегистрировано сквозным `docs/architecture/adr/adr-0040-*`. Возврат в анализ не нужен — +ТЗ удовлетворяется без нарушения принципов архитектуры. diff --git a/docs/work-items/ORCH-109/12-review.md b/docs/work-items/ORCH-109/12-review.md new file mode 100644 index 0000000..4308977 --- /dev/null +++ b/docs/work-items/ORCH-109/12-review.md @@ -0,0 +1,119 @@ +--- +verdict: APPROVED # APPROVED | REQUEST_CHANGES — строго одно из двух, UPPERCASE +work_item: ORCH-109 +stage: review +author_agent: reviewer +status: approved +created_at: 2026-06-14 +model_used: claude-opus-4-8 +type: review +work_item_id: ORCH-109 +version: 3 +--- + +# Review ORCH-109 + +## Summary + +Две аддитивные изолированные правки подсистемы запуска (`launcher`) — **launch-стамп модели** в +`agent_runs.model` (D1/FR-1) и **поднятые per-role wall-clock бюджеты** developer/reviewer с +синхронным поднятием reaper (D3/D4/FR-3) — реализованы **корректно и точно по ADR**. Контракты +неприкосновенны: в `src/` изменены **только** `launcher.py` и `config.py`; ни одной строки +`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / `_parse_*` / machine-verdict / `CREATE TABLE` / +`ALTER TABLE` в диффе нет (AC-9 верифицирован grep'ом по диффу). Зафиксированные маркер-инварианты +**ORCH-087** (стамп эффорта объединён в один `UPDATE`, `(effort or None)` сохранён) и **ORCH-065** +(reaper поднят синхронно `3600→5400`, `5400 > 3600+20=3620`) — целы. Покрытие исчерпывающее: новый +`tests/test_orch109_timeout_model.py` (TC-01…TC-12, детерминированный, без сети/CLI), обновлены +`tests/test_config.py` (reaper-дефолт 5400) и `tests/test_launcher.py` (лестница `_resolve_timeout`). + +Независимая верификация reviewer'а: целевые тесты зелёные — `test_orch109_timeout_model.py` + +`test_config.py` + `test_launcher.py` = **75 passed**; зависимые подсистемы FR-2/FR-4 +(`usage`/`notifications`/`tracker`) = **231 passed**. Полный регресс зелёный (см. `13-test-report.md`). +Открытых findings P0/P1/P2 нет → вердикт **APPROVED**. + +## Оси проверки + +1. **Соответствие ТЗ (02-trz / 03-acceptance):** FR-1…FR-6 реализованы; AC-1…AC-10 покрыты тестами + TC-01…TC-12 буквально по матрице AC↔FR. + - FR-1/AC-1 (TC-04): `_spawn` пишет резолвенную модель в `agent_runs.model` объединённым + `UPDATE agent_runs SET model=?, effort=? WHERE id=?` с `(model or None, effort or None, run_id)` + рядом со стампом эффорта; пустой резолв → `NULL`; стамп = `resolve_agent_model` (single source). + - FR-2/AC-2 (TC-06/07): `usage.record_usage` использует `model=COALESCE(?, model)` (сверено по + `src/usage.py`) — `usage=None`/`model=None` не затирает launch-стамп; непустая модель уточняет. + Кода `usage.py` PR не трогает (корректно — семантика уже верна), зафиксировано регресс-тестом. + - FR-3/AC-3 (TC-01/02): `_resolve_timeout` отдаёт поднятый бюджет developer/reviewer и неизменный + 1800 прочим ролям (analyst/architect/tester/deployer/unknown/None); бюджеты конфигурируемы. + - FR-3/AC-4 (TC-03): малформный `agent_timeout_overrides_json` и непозитивный/нечисловой + выделенный ключ `[0,-5,"abc"]` → откат на глобальный дефолт + WARNING, never-break. + - NFR-4/AC-5 (TC-08): инвариант reaper подтверждён на shipped-дефолтах (`5400 > 3600+20`). + - FR-4/AC-6 (TC-09): строка стадии рендерит `· opus-4-8 · xhigh` для `exit_code=-9`; присутствует + negative-guard (немаркированный -9 → суффикс опущен). + - FR-4/NFR-6/AC-7 (TC-10): `get_running_agents` отдаёт модель для running-job (in-flight). + - FR-5/AC-8 (TC-11): timeout-killed прогон developer/reviewer не вызывает `_try_advance_stage` + (роутится в `_finalize_job`); присутствует позитивный контроль (clean exit → advance). + - AC-9 (TC-12) + AC-10: контракты/схема нетронуты; документация и регресс зелёные. + +2. **Соответствие ADR (06-adr ADR-001 + сквозной adr-0040):** D1–D6 реализованы дословно. Лестница + `_resolve_timeout` (overrides_json → выделенный ключ роли → глобальный дефолт), выделенные ключи + `agent_timeout_developer_s=3600`/`agent_timeout_reviewer_s=3000`, `reaper_max_running_s` 3600→5400. + **Трассировка маркеров (TRACEABILITY):** правка касается блоков с маркерами ORCH-087 (стамп + эффорта) и ORCH-065 (reaper Tier-3) — оба зафиксированных инварианта сверены с их ADR и не + сломаны (эффорт-стамп сохранён в объединённом `UPDATE`; reaper-неравенство пересчитано и поднято + синхронно). Нарушений глобальных ADR нет. + +3. **Качество кода:** never-raise сохранён (`try/except` + WARNING вокруг стамп-`UPDATE`; + непозитивный/нечисловой выделенный ключ → откат + WARNING). Докстринг `_resolve_timeout` и + паспорт-комментарии `config.py` точны. Тесты содержательны: изоляция стамп-сбоя (TC-05, + `_RaisingConn` бьёт только по launch-`UPDATE`), параметризация `[0,-5,"abc"]`, негативный guard + (TC-09b), позитивный контроль (TC-11c). **Регресс-тест-фиксатор инцидента ORCH-104** присутствует + (ORCH-019 BR-4 удовлетворён) — весь тест-файл пинит дефектное и исправленное поведение. + +4. **Документация (приоритетная ось):** `src/` изменён → документация обновлена в том же PR + (golden source синхронизирован с кодом): + `CHANGELOG.md` / `CLAUDE.md` (паспорт) / `docs/architecture/README.md` (бюллет Agent Launcher + + ссылка на adr-0040) / `docs/architecture/internals.md` (оба упоминания «30 мин» → per-role) / + `README.md` front-page «### Watchdog» (per-role бюджеты + Tier-3 backstop 90м) / `.env.example` + (5 ключей agent-timeout + `ORCH_REAPER_MAX_RUNNING_S`=5400) / `config.py`-паспорт / детальный + ADR-001 + сквозной adr-0040. Обзорная витрина `docs/overview/` правки не требует — упоминания + watchdog концептуальны (sidecar-наблюдатель, «следит за процессом»), конкретного числа тайм-аута + витрина не несёт → устаревшего факта не возникает (ORCH-011/079 — нет finding). PR не закрывает + пункт `README.md` «Известные ограничения». + +## Findings + +### P0 — Blocker +- (нет) + +### P1 — Must fix +- (нет) + +### P2 — Should fix +- (нет) + +### P3 — Nice-to-have +- [ ] ADR-001 (`status: proposed`) и adr-0040 (`Proposed`) на merge разумно перевести в `Accepted` + (косметика статуса ADR; на гейты/код не влияет, не блокер). + +## Документация + +**Обновлено в этом PR (golden source синхронизирован с кодом):** +- `CHANGELOG.md` — детальная запись ORCH-109 (`fix`, D1/D3/D4, FR-4/FR-5 структурно). ✅ +- `CLAUDE.md` — паспорт (блок «Стек», абзац launcher). ✅ +- `docs/architecture/README.md` — бюллет Agent Launcher (ссылка на adr-0040). ✅ +- `docs/architecture/internals.md` — watchdog «30 мин» → per-role (стр. ~96 и ~262). ✅ +- `README.md` — front-page «### Watchdog» (стр. ~295) → per-role бюджеты + Tier-3 backstop. ✅ +- `.env.example` — новый блок agent-timeout (5 ключей) + `ORCH_REAPER_MAX_RUNNING_S` 3600→5400. ✅ +- `src/config.py` — паспорт-комментарий ORCH-7/ORCH-109 + reaper-инвариант. ✅ +- ADR — `docs/work-items/ORCH-109/06-adr/ADR-001-…` (детальный) + `docs/architecture/adr/adr-0040-…` + (сквозной). ✅ + +**Обзорная витрина `docs/overview/` (ORCH-011/ORCH-079):** правки не требует — упоминания watchdog +концептуальны, конкретного числа тайм-аута витрина не несёт, поэтому устаревшего факта не возникает. + +**Прочее (не findings):** +- AC-9 верифицирован по диффу: в `src/` изменены только `launcher.py` и `config.py`; ни одной строки + `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/`CREATE TABLE`/`ALTER TABLE`. +- Целевой регресс reviewer'а зелёный: 75 (ORCH-109/config/launcher) + 231 (usage/notifications/ + tracker) passed; полный регресс — `13-test-report.md`. + + diff --git a/docs/work-items/ORCH-109/13-test-report.md b/docs/work-items/ORCH-109/13-test-report.md new file mode 100644 index 0000000..d4a4a4a --- /dev/null +++ b/docs/work-items/ORCH-109/13-test-report.md @@ -0,0 +1,82 @@ +--- +result: PASS +work_item: ORCH-109 +stage: testing +author_agent: tester +status: pass +created_at: 2026-06-14 +model_used: claude-opus-4-8 +type: test-report +work_item_id: ORCH-109 +--- + +# Test Report — ORCH-109 — timeout budgets + launch-time model telemetry для developer/reviewer + +> Машинный вердикт читается ТОЛЬКО из frontmatter. Канонический ключ — `result:`. + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 (plugins: cov-5.0.0, anyio-4.13.0, asyncio-0.23.8) +- Дата: 2026-06-14 +- Worktree: `feature/ORCH-109-orch-timeout-budgets-launch-ti` + (`/repos/_wt/orchestrator/feature_ORCH-109-orch-timeout-budgets-launch-ti/`) + +## Smoke API (read-only, прод не трогался) +| Endpoint | Результат | +|----------|-----------| +| `GET /health` | PASS — `{"status":"ok","service":"orchestrator"}` | +| `GET /status` | PASS — задача 98 (ORCH-109) в стадии `testing`, агент не запущен | +| `GET /queue` | PASS — блок `serial_gate` присутствует (ORCH-088); блок `auto_labels` присутствует (ORCH-089) | + +## Результаты + +### Полный регресс +`pytest tests/ -q` → **1899 passed, 1 warning in 516.70s (0:08:36)** (exit 0). +Единственное предупреждение — `PydanticDeprecatedSince20` (class-based config, pre-existing, +не связано с ORCH-109). Прод-контейнер не затрагивался. + +### Профильная сюита +`pytest tests/test_orch109_timeout_model.py -v` → **25 passed** (exit 0, 13.50s). +Покрывает TC-01…TC-12 (+ доп. варианты: configurable-keys, overrides-json-wins, параметризация +non-positive `[0,-5,abc]`, clean-exit advances, unstamped-killed drops suffix). + +## Сопоставление с тест-планом (`04-test-plan.yaml`) + +| TC ID | Описание | Тест-функция(и) | Результат | +|-------|----------|-----------------|-----------| +| TC-01 | `_resolve_timeout('developer'/'reviewer')` возвращает поднятый бюджет | `test_tc01_developer_reviewer_raised`, `test_tc01_dedicated_keys_are_configurable`, `test_tc01_overrides_json_wins_over_dedicated` | PASS | +| TC-02 | Прочие роли (analyst/architect/tester/deployer) → глобальный 1800 | `test_tc02_other_roles_use_global_default` | PASS | +| TC-03 | Малформный конфиг → откат на дефолт + WARNING, без исключения | `test_tc03_malformed_overrides_json_never_raises`, `test_tc03_non_positive_dedicated_falls_back[0/-5/abc]` | PASS | +| TC-04 | Launch стампит `agent_runs.model` (непустую) рядом с effort | `test_tc04_spawn_stamps_model_and_effort` | PASS | +| TC-05 | Стамп изолирован: сбой записи не пробрасывает исключение (never-raise) | `test_tc05_stamp_failure_is_isolated` | PASS | +| TC-06 | `record_usage(None)`/`model=None` НЕ затирают launch-стамп (COALESCE) | `test_tc06_record_usage_none_preserves_model`, `test_tc06_record_usage_model_none_preserves_model` | PASS | +| TC-07 | `record_usage` с непустой model уточняет/проставляет значение | `test_tc07_record_usage_nonempty_model_enriches_blank`, `test_tc07_record_usage_refines_existing_model` | PASS | +| TC-08 | Sanity reaper-инварианта: `reaper_max_running_s > max(timeout)+grace` | `test_tc08_shipped_defaults_satisfy_invariant`, `test_tc08_resolved_max_is_developer` | PASS | +| TC-09 | `_stage_line` для `exit_code=-9` рендерит ` · · ` (model не null) | `test_tc09_killed_run_renders_model_effort`, `test_tc09_unstamped_killed_run_drops_model_suffix` | PASS | +| TC-10 | `get_running_agents` отдаёт непустую model для running-job (in-flight) | `test_tc10_running_job_exposes_model` | PASS | +| TC-11 | Анти-salvage: killed developer/reviewer (`exit_code=-9`) не продвигает стадию | `test_tc11_killed_developer_run_does_not_advance`, `test_tc11_killed_reviewer_run_does_not_advance`, `test_tc11_clean_exit_advances` | PASS | +| TC-12 | Анти-регресс: STAGE_TRANSITIONS/QG_CHECKS/схема `agent_runs` не изменены | `test_tc12_stage_transitions_unchanged`, `test_tc12_agent_runs_model_effort_columns_preexist`, `test_tc12_qg_checks_registry_present` | PASS | + +**Все 12 TC выполнены и сопоставлены.** + +## Сопоставление с критериями приёмки (`03-acceptance-criteria.md`) + +| AC | Критерий | Покрытие | Результат | +|----|----------|----------|-----------| +| AC-1 | Модель стампится в `agent_runs.model` на launch | TC-04 | PASS | +| AC-2 | Постфактум-enrich не затирает launch-стамп при оборванном JSON | TC-06, TC-07 | PASS | +| AC-3 | Тайм-аут developer/reviewer поднят и конфигурируем без влияния на прочие | TC-01, TC-02 | PASS | +| AC-4 | Малформный timeout-конфиг → безопасный откат (never-break) | TC-03 | PASS | +| AC-5 | Reaper-инвариант сохранён | TC-08 | PASS | +| AC-6 | Строка стадии трекера показывает model+effort при timeout/kill | TC-09 | PASS | +| AC-7 | In-flight видимость модели в `/metrics`/`/queue` | TC-10 | PASS | +| AC-8 | Timeout-killed прогон не продвигает стадию (анти-salvage) | TC-11 | PASS | +| AC-9 | Неприкосновенность контрактов и схемы | TC-12 | PASS | +| AC-10 | Документация и полный регресс зелёный | full regress (1899 passed) + review APPROVED | PASS | + +**Все 10 AC покрыты и зелёные.** + +## Итог +**PASS** — полный регресс зелёный (1899 passed, exit 0), профильная сюита ORCH-109 зелёная +(25 passed), smoke API OK (`serial_gate`/`auto_labels` присутствуют в `/queue`), каждый TC из +тест-плана выполнен и сопоставлен с критериями приёмки. Задача переходит на `deploy-staging`. diff --git a/docs/work-items/ORCH-109/14-deploy-log.md b/docs/work-items/ORCH-109/14-deploy-log.md new file mode 100644 index 0000000..75813c9 --- /dev/null +++ b/docs/work-items/ORCH-109/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-109 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/docs/work-items/ORCH-109/17-security-report.md b/docs/work-items/ORCH-109/17-security-report.md new file mode 100644 index 0000000..66aa527 --- /dev/null +++ b/docs/work-items/ORCH-109/17-security-report.md @@ -0,0 +1,25 @@ +--- +security_status: PASS +secrets_found: 0 +deps_blocking: 0 +deps_warning: 4 +deps_audit_degraded: false +--- +# Security Report — ORCH-109 + +Детерминированный security-гейт (ORCH-022): secret-scanning (gitleaks, offline) + dependency audit (pip-audit). Машинный вердикт читается ТОЛЬКО из frontmatter выше. + +## Verdict +clean: 0 secrets, 0 blocking CVE(s) + +## Secrets +- None + +## Dependencies (blocking) +- None + +## Dependencies (warning) +- `pytest==8.3.3` — GHSA-6w46-j5rx-g56g severity=UNKNOWN fix=9.0.3 +- `starlette==0.38.6` — PYSEC-2026-161 severity=UNKNOWN fix=1.0.1 +- `starlette==0.38.6` — GHSA-f96h-pmfr-66vw severity=UNKNOWN fix=0.40.0 +- `starlette==0.38.6` — GHSA-2c2j-9gv5-cj73 severity=UNKNOWN fix=0.47.2 diff --git a/src/agents/launcher.py b/src/agents/launcher.py index 1116039..c065638 100644 --- a/src/agents/launcher.py +++ b/src/agents/launcher.py @@ -563,14 +563,26 @@ class AgentLauncher: # so this is the only reliable source for the tracker's "· model · effort" # line. Empty resolve (no --effort flag) -> NULL so the suffix is omitted. # Reuses the still-open conn; never blocks the launch. + # + # ORCH-109 (D1): stamp the resolved MODEL in the SAME UPDATE. Previously the + # model was only written post-hoc from the final usage-JSON (usage.record_usage, + # model=COALESCE(?, model)); a timeout-killed run never emits that JSON, so the + # model stayed NULL exactly when an incident needs it. Resolving it here is + # deterministic (resolve_agent_model above), so the value is present from launch, + # survives a timeout-kill (-9), and is visible in-flight in /metrics & /queue. + # The post-hoc record_usage stays an ENRICHMENT (COALESCE keeps the launch stamp + # when the JSON model is None/missing). Empty resolve (model == "", CLI default + # with no --model) -> NULL, symmetric with `effort or None`, so the tracker's + # model suffix is correctly omitted. never-raise: failure is isolated + WARNING; + # the launch continues (model_flag is built from the local `model`, not the DB). try: conn.execute( - "UPDATE agent_runs SET effort=? WHERE id=?", - (effort or None, run_id), + "UPDATE agent_runs SET model=?, effort=? WHERE id=?", + (model or None, effort or None, run_id), ) conn.commit() except Exception as e: - logger.warning(f"effort stamp failed for run_id={run_id}: {e}") + logger.warning(f"model/effort stamp failed for run_id={run_id}: {e}") model_flag = f"--model {model} " if model else "" effort_flag = f"--effort {effort} " if effort else "" # ORCH-074 (G2): agent_fallback_model is read directly here, bypassing @@ -658,16 +670,34 @@ class AgentLauncher: notify_agent_started(run_id, agent, task_id) return run_id + # ORCH-109 (D3): dedicated raised-budget keys for the two HEAVY roles. Maps the + # role to its Settings attribute; resolved BELOW the operator JSON escape-hatch + # and ABOVE the global default. A role absent here keeps the global default. + _TIMEOUT_ROLE_KEYS = { + "developer": "agent_timeout_developer_s", + "reviewer": "agent_timeout_reviewer_s", + } + @staticmethod def _resolve_timeout(agent: str = None) -> int: - """ORCH-7 (M-2): resolve the wall-clock timeout for an agent. + """ORCH-7 (M-2) + ORCH-109 (D3): resolve the wall-clock timeout for an agent. - Per-agent override from settings.agent_timeout_overrides_json (a JSON object - like {"reviewer": 3600}) wins; otherwise the global default - settings.agent_timeout_seconds is used. A malformed override JSON is ignored - (falls back to the default) and only logged, so a bad env never bricks runs. + Deterministic priority ladder (highest first): + 1. settings.agent_timeout_overrides_json[agent] -- operator escape-hatch, + wins for ANY role (full BC). A malformed JSON is ignored + logged. + 2. dedicated per-role key (ORCH-109): developer -> agent_timeout_developer_s + (3600), reviewer -> agent_timeout_reviewer_s (3000). A non-positive / + non-int value is ignored + logged (never-break) and falls through to (3). + 3. settings.agent_timeout_seconds -- the global default (1800) for every + other role (analyst/architect/tester/deployer), byte-for-byte as before. + + Never raises: any bad config degrades to the global default so a bad env + never bricks runs. Cross-invariant (ORCH-065): max(resolved) + grace must + stay < reaper_max_running_s (raised to 5400 in lockstep; see config.py). """ default = settings.agent_timeout_seconds + + # (1) operator JSON override -- highest priority, unchanged semantics. raw = (settings.agent_timeout_overrides_json or "").strip() if agent and raw: try: @@ -676,6 +706,22 @@ class AgentLauncher: return int(overrides[agent]) except (ValueError, TypeError) as e: logger.warning(f"Invalid agent_timeout_overrides_json, using default: {e}") + + # (2) dedicated per-role raised budget (ORCH-109 D3/D4). + key = AgentLauncher._TIMEOUT_ROLE_KEYS.get(agent) + if key is not None: + try: + value = int(getattr(settings, key)) + if value > 0: + return value + logger.warning( + f"Non-positive {key}={value!r}; falling back to " + f"agent_timeout_seconds={default}" + ) + except (ValueError, TypeError) as e: + logger.warning(f"Invalid {key} for agent '{agent}', using default: {e}") + + # (3) global default. return default def _watchdog(self, pid: int, run_id: int, timeout: int = None, diff --git a/src/config.py b/src/config.py index 36ef139..d4b0a51 100644 --- a/src/config.py +++ b/src/config.py @@ -120,10 +120,28 @@ class Settings(BaseSettings): # (env ORCH_AGENT_KILL_GRACE_SECONDS). # agent_timeout_overrides_json -> optional per-agent override JSON object, # e.g. {"reviewer": 3600, "architect": 2700} - # (env ORCH_AGENT_TIMEOUT_OVERRIDES_JSON). + # (env ORCH_AGENT_TIMEOUT_OVERRIDES_JSON). HIGHEST + # priority escape-hatch in _resolve_timeout (wins for + # any role). + # ORCH-109 (D3/D4): raised wall-clock budgets for the two HEAVY roles. + # agent_timeout_developer_s -> developer is the bottleneck (effort xhigh, + # coding/agentic); 3600s/60m (env + # ORCH_AGENT_TIMEOUT_DEVELOPER_S). + # agent_timeout_reviewer_s -> reviewer reads a large diff + writes the review + # (high reasoning); 3000s/50m (env + # ORCH_AGENT_TIMEOUT_REVIEWER_S). + # _resolve_timeout ladder: overrides_json[agent] > dedicated role key > + # agent_timeout_seconds (other roles stay at 1800, byte-for-byte). A malformed + # JSON / non-positive dedicated value falls back to agent_timeout_seconds + + # WARNING (never-break). The defaults ARE the prod budget (ORCH-101 canon: empty + # .env reproduces prod). CROSS-INVARIANT (ORCH-065): reaper_max_running_s MUST + # stay > max(resolved timeout) + agent_kill_grace_seconds; raised in lockstep to + # 5400 below (5400 > 3600 + 20 = 3620). agent_timeout_seconds: int = 1800 agent_kill_grace_seconds: int = 20 agent_timeout_overrides_json: str = "" + agent_timeout_developer_s: int = 3600 + agent_timeout_reviewer_s: int = 3000 # ORCH-41: per-agent LLM model. Empty -> agent_model_default. Resolution order: # project-override (projects_json agent_models) > ORCH_AGENT_MODEL_ > @@ -480,6 +498,9 @@ class Settings(BaseSettings): # reaper_max_running_s -> Tier-3 backstop ceiling: a job 'running' longer than # this is reaped even when liveness is unknowable. MUST be # > max agent_timeout + grace so a legit agent is safe. + # ORCH-109 (D4): raised 3600 -> 5400 in lockstep with the + # developer budget (5400 > 3600 + 20 = 3620; headroom 1780s + # also covers the monitor finalization window). # reaper_finalize_grace_s -> Tier-2 anti-false-positive: a LIVE monitor writes # agent_runs.exit_code FIRST, THEN does git commit/push + # PR + Plane usage comments (seconds..minutes) and only @@ -494,7 +515,7 @@ class Settings(BaseSettings): reaper_enabled: bool = True reaper_interval_s: int = 60 reaper_dead_ticks: int = 2 - reaper_max_running_s: int = 3600 + reaper_max_running_s: int = 5400 reaper_finalize_grace_s: int = 300 lease_reclaim_enabled: bool = True diff --git a/tests/test_config.py b/tests/test_config.py index 899259b..7157729 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -195,7 +195,9 @@ def test_reaper_settings_defaults(monkeypatch): assert s.reaper_enabled is True assert s.reaper_interval_s == 60 assert s.reaper_dead_ticks == 2 - assert s.reaper_max_running_s == 3600 + # ORCH-109 (D4): raised 3600 -> 5400 in lockstep with the developer budget so the + # Tier-3 backstop stays > max(agent_timeout)+grace (5400 > 3600 + 20 = 3620). + assert s.reaper_max_running_s == 5400 assert s.lease_reclaim_enabled is True diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 4569e11..9b0a7dc 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -160,12 +160,19 @@ class TestDeadCodeRemoved: # ORCH-7 (M-2): configurable timeout + per-agent override # --------------------------------------------------------------------------- class TestResolveTimeout: - """M-2: _resolve_timeout honours a per-agent JSON override, else the default.""" + """M-2: _resolve_timeout honours a per-agent JSON override, else the default. + + ORCH-109 (D3): the ladder grew a middle level — dedicated raised budgets for + developer/reviewer between the JSON escape-hatch and the global default. These + tests are updated to assert the global default via a NON-raised role; the new + dedicated-key ladder is covered in full by tests/test_orch109_timeout_model.py. + """ def test_default_when_no_override(self, monkeypatch): monkeypatch.setattr(settings, "agent_timeout_seconds", 1800) monkeypatch.setattr(settings, "agent_timeout_overrides_json", "") - assert AgentLauncher._resolve_timeout("developer") == 1800 + # ORCH-109: a role WITHOUT a dedicated key keeps the global default. + assert AgentLauncher._resolve_timeout("analyst") == 1800 assert AgentLauncher._resolve_timeout(None) == 1800 def test_override_for_specific_agent(self, monkeypatch): @@ -173,16 +180,19 @@ class TestResolveTimeout: monkeypatch.setattr( settings, "agent_timeout_overrides_json", '{"reviewer": 3600, "architect": 2700}' ) + # JSON override stays the HIGHEST priority for ANY role (full BC). assert AgentLauncher._resolve_timeout("reviewer") == 3600 assert AgentLauncher._resolve_timeout("architect") == 2700 - # an agent not in the override map falls back to the default - assert AgentLauncher._resolve_timeout("developer") == 1800 + # ORCH-109: a role not in the override map AND without a dedicated key + # (tester) falls back to the global default. + assert AgentLauncher._resolve_timeout("tester") == 1800 def test_malformed_override_falls_back_to_default(self, monkeypatch): monkeypatch.setattr(settings, "agent_timeout_seconds", 1800) monkeypatch.setattr(settings, "agent_timeout_overrides_json", "{not-json") - # must not raise, must return the default - assert AgentLauncher._resolve_timeout("reviewer") == 1800 + # must not raise; a role without a dedicated key returns the global default + # (ORCH-109: developer/reviewer would return their dedicated budget instead). + assert AgentLauncher._resolve_timeout("architect") == 1800 class TestWatchdogGracefulKill: diff --git a/tests/test_orch109_timeout_model.py b/tests/test_orch109_timeout_model.py new file mode 100644 index 0000000..aa4b602 --- /dev/null +++ b/tests/test_orch109_timeout_model.py @@ -0,0 +1,554 @@ +"""ORCH-109: timeout budgets + launch-time model telemetry for developer/reviewer. + +Covers FR-1..FR-6 / AC-1..AC-10 through TC-01..TC-12 (04-test-plan.yaml). Fully +deterministic: an isolated temp SQLite DB + synthetic agent_runs / jobs rows; no +network, no Claude CLI subprocess. Settings are monkeypatched / overridden. + +Two production changes under test (ADR-001): + * D1 — launcher._spawn stamps the resolved model into agent_runs.model in the + SAME UPDATE as the effort stamp, so the model is present from launch and + survives a timeout-kill / is visible in-flight. + * D3/D4 — launcher._resolve_timeout grows a dedicated per-role budget level + (developer 3600 / reviewer 3000) between the JSON escape-hatch and the global + default; reaper_max_running_s raised 3600 -> 5400 in lockstep (ORCH-065). +FR-2 (COALESCE preserve), FR-4/NFR-6 (kill / in-flight visibility) and FR-5 +(anti-salvage) are STRUCTURAL guarantees already present in the code — pinned here +as regression tests, not new branches. +""" +import os +import sqlite3 +import tempfile + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +_test_db = os.path.join(tempfile.gettempdir(), "test_orch109_timeout_model.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ.setdefault("ORCH_REPOS_DIR", tempfile.gettempdir()) + +import pytest # noqa: E402 + +import src.db as db_module # noqa: E402 +from src.db import init_db, get_db, get_running_agents # noqa: E402 +from src.config import settings, Settings # noqa: E402 +from src.agents.launcher import AgentLauncher, resolve_agent_model # noqa: E402 +from src import usage as U # noqa: E402 +from src import notifications as N # noqa: E402 + + +@pytest.fixture(autouse=True) +def setup_db(monkeypatch): + # get_db() reads settings.db_path live; pin it to our isolated DB. + monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + # render-only tests: never consult the live Plane overlay (no network). + monkeypatch.setattr(N._get_settings(), "tracker_live_status", False, raising=False) + yield + if os.path.exists(_test_db): + os.unlink(_test_db) + + +# --------------------------------------------------------------------------- # +# TC-01..TC-03 — _resolve_timeout dedicated-budget ladder (FR-3, AC-3 / AC-4) +# --------------------------------------------------------------------------- # +class TestResolveTimeoutLadder: + """The priority ladder: overrides_json > dedicated role key > global default.""" + + def _pin(self, monkeypatch, *, dev=3600, rev=3000, default=1800, overrides=""): + monkeypatch.setattr(settings, "agent_timeout_seconds", default) + monkeypatch.setattr(settings, "agent_timeout_overrides_json", overrides) + monkeypatch.setattr(settings, "agent_timeout_developer_s", dev) + monkeypatch.setattr(settings, "agent_timeout_reviewer_s", rev) + + def test_tc01_developer_reviewer_raised(self, monkeypatch): + """TC-01/AC-3: developer/reviewer resolve to their raised dedicated budget.""" + self._pin(monkeypatch) + assert AgentLauncher._resolve_timeout("developer") == 3600 + assert AgentLauncher._resolve_timeout("reviewer") == 3000 + + def test_tc01_dedicated_keys_are_configurable(self, monkeypatch): + """TC-01/AC-3: the budgets are config-driven, not hardcoded.""" + self._pin(monkeypatch, dev=4200, rev=2400) + assert AgentLauncher._resolve_timeout("developer") == 4200 + assert AgentLauncher._resolve_timeout("reviewer") == 2400 + + def test_tc02_other_roles_use_global_default(self, monkeypatch): + """TC-02/AC-3: roles without a dedicated key keep the global default (1800).""" + self._pin(monkeypatch) + for role in ("analyst", "architect", "tester", "deployer"): + assert AgentLauncher._resolve_timeout(role) == 1800 + # unknown role / None also fall through to the global default. + assert AgentLauncher._resolve_timeout("unknown-role") == 1800 + assert AgentLauncher._resolve_timeout(None) == 1800 + + def test_tc01_overrides_json_wins_over_dedicated(self, monkeypatch): + """AC-3: the operator JSON escape-hatch stays HIGHEST priority for ANY role.""" + self._pin(monkeypatch, overrides='{"developer": 1234, "reviewer": 999}') + assert AgentLauncher._resolve_timeout("developer") == 1234 + assert AgentLauncher._resolve_timeout("reviewer") == 999 + + def test_tc03_malformed_overrides_json_never_raises(self, monkeypatch): + """TC-03/AC-4: malformed JSON is ignored; resolution still succeeds (never-break).""" + self._pin(monkeypatch, overrides="{not-json") + # malformed JSON ignored -> developer still resolves via its dedicated key. + assert AgentLauncher._resolve_timeout("developer") == 3600 + # a role without a dedicated key falls through to the global default. + assert AgentLauncher._resolve_timeout("analyst") == 1800 + + @pytest.mark.parametrize("bad", [0, -5, "abc"]) + def test_tc03_non_positive_dedicated_falls_back(self, monkeypatch, bad): + """TC-03/AC-4: an absurd/non-positive/non-int dedicated value -> global default.""" + self._pin(monkeypatch, dev=bad) + # must NOT raise; falls back to agent_timeout_seconds + WARNING. + assert AgentLauncher._resolve_timeout("developer") == 1800 + + +# --------------------------------------------------------------------------- # +# TC-04 / TC-05 — launch-time model stamp in _spawn (FR-1, AC-1 + NFR-2) +# --------------------------------------------------------------------------- # +class TestLaunchModelStamp: + """_spawn writes the resolved model to agent_runs.model at launch (next to effort).""" + + def _seed_task(self, repo="orchestrator", branch="feature/ORCH-109-x", wid="ORCH-109"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) " + "VALUES (?,?,?,?,?,?)", + ("p1", wid, repo, branch, "development", "t"), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + def _fake_spawn_env(self, tmp_path, monkeypatch, repo="orchestrator"): + """Fake every OS/process side-effect so _spawn touches only the DB.""" + import src.agents.launcher as L + (tmp_path / repo).mkdir() + monkeypatch.setattr(L.settings, "repos_dir", str(tmp_path), raising=False) + monkeypatch.setattr(L.settings, "runs_dir", str(tmp_path / "runs"), raising=False) + monkeypatch.setattr(L, "ensure_worktree", lambda r, b: str(tmp_path / repo)) + monkeypatch.setattr("src.projects.get_project_by_repo", lambda r: None) + + class _Proc: + pid = 4242 + + monkeypatch.setattr(L.subprocess, "Popen", lambda *a, **k: _Proc()) + + class _T: + def __init__(self, *a, **k): + pass + + def start(self): + pass + + monkeypatch.setattr(L.threading, "Thread", _T) + monkeypatch.setattr(L, "notify_agent_started", lambda *a, **k: None) + return L + + def test_tc04_spawn_stamps_model_and_effort(self, tmp_path, monkeypatch): + """TC-04/AC-1: after _spawn the run row carries the resolved model AND effort.""" + L = self._fake_spawn_env(tmp_path, monkeypatch) + # Deterministic resolve: developer -> claude-opus-4-8 (default) / xhigh (floor). + monkeypatch.setattr(L.settings, "agent_model_developer", "", raising=False) + monkeypatch.setattr(L.settings, "agent_model_default", "claude-opus-4-8", raising=False) + monkeypatch.setattr(L.settings, "agent_effort_developer", "", raising=False) + monkeypatch.setattr(L.settings, "agent_effort_default", "", raising=False) + + tid = self._seed_task() + run_id = L.AgentLauncher()._spawn( + "developer", "orchestrator", task_content=None, task_id=tid + ) + + conn = get_db() + row = conn.execute( + "SELECT model, effort FROM agent_runs WHERE id=?", (run_id,) + ).fetchone() + conn.close() + assert row["model"] == "claude-opus-4-8" + assert row["effort"] == "xhigh" + # The stamp matches the resolver — single source of truth. + assert row["model"] == resolve_agent_model("developer", None) + + def test_tc05_stamp_failure_is_isolated(self, tmp_path, monkeypatch): + """TC-05/NFR-2: a failing model/effort stamp does NOT propagate out of _spawn.""" + L = self._fake_spawn_env(tmp_path, monkeypatch) + real_get_db = db_module.get_db + + class _RaisingConn: + """Delegates to a real conn but raises on the launch stamp UPDATE only.""" + + def __init__(self, real): + self._real = real + + def execute(self, sql, *a, **k): + if "SET model=?, effort=?" in sql: + raise sqlite3.OperationalError("simulated stamp failure") + return self._real.execute(sql, *a, **k) + + def commit(self): + return self._real.commit() + + def close(self): + return self._real.close() + + def __getattr__(self, name): + return getattr(self._real, name) + + monkeypatch.setattr(L, "get_db", lambda: _RaisingConn(real_get_db())) + + tid = self._seed_task() + # Must NOT raise even though the stamp UPDATE blows up. + run_id = L.AgentLauncher()._spawn( + "developer", "orchestrator", task_content=None, task_id=tid + ) + assert run_id is not None + + # The run row exists; model stayed NULL (stamp failed) — launch unharmed. + conn = real_get_db() + row = conn.execute( + "SELECT id, model FROM agent_runs WHERE id=?", (run_id,) + ).fetchone() + conn.close() + assert row is not None + assert row["model"] is None + + +# --------------------------------------------------------------------------- # +# TC-06 / TC-07 — post-hoc enrich preserves / refines the launch stamp (FR-2) +# --------------------------------------------------------------------------- # +class TestRecordUsagePreservesStamp: + """record_usage (model=COALESCE(?, model)) never clobbers a launch-stamped model.""" + + def _run_with_model(self, model="claude-opus-4-8", agent="developer"): + conn = get_db() + cur = conn.execute( + "INSERT INTO agent_runs (task_id, agent, model) VALUES (?,?,?)", + (1, agent, model), + ) + rid = cur.lastrowid + conn.commit() + conn.close() + return rid + + def _model_of(self, rid): + conn = get_db() + row = conn.execute("SELECT model FROM agent_runs WHERE id=?", (rid,)).fetchone() + conn.close() + return row["model"] + + def test_tc06_record_usage_none_preserves_model(self): + """TC-06/AC-2: usage=None (no final JSON, e.g. timeout) keeps the launch stamp.""" + rid = self._run_with_model() + U.record_usage(rid, None) # must not raise + assert self._model_of(rid) == "claude-opus-4-8" + + def test_tc06_record_usage_model_none_preserves_model(self): + """TC-06/AC-2: a usage JSON with model=None keeps the launch stamp (COALESCE).""" + rid = self._run_with_model() + U.record_usage(rid, {"input_tokens": 10, "output_tokens": 5, "model": None}) + assert self._model_of(rid) == "claude-opus-4-8" + + def test_tc07_record_usage_nonempty_model_enriches_blank(self): + """TC-07/AC-2: a non-empty model in the JSON sets a blank (CLI-default) stamp.""" + rid = self._run_with_model(model=None) + U.record_usage( + rid, {"input_tokens": 1, "output_tokens": 1, "model": "claude-opus-4-8"} + ) + assert self._model_of(rid) == "claude-opus-4-8" + + def test_tc07_record_usage_refines_existing_model(self): + """TC-07/AC-2: a fuller provider-prefixed id refines a bare launch stamp.""" + rid = self._run_with_model(model="claude-opus-4-8") + U.record_usage( + rid, + {"input_tokens": 1, "output_tokens": 1, "model": "tokenator/claude-opus-4-8"}, + ) + assert self._model_of(rid) == "tokenator/claude-opus-4-8" + + +# --------------------------------------------------------------------------- # +# TC-08 — reaper cross-invariant (NFR-4, AC-5) +# --------------------------------------------------------------------------- # +class TestReaperInvariant: + """reaper_max_running_s MUST stay > max(resolved timeout) + grace (ORCH-065).""" + + def test_tc08_shipped_defaults_satisfy_invariant(self, monkeypatch): + """TC-08/AC-5: the canonical shipped defaults hold the invariant.""" + for name in ( + "ORCH_AGENT_TIMEOUT_SECONDS", + "ORCH_AGENT_KILL_GRACE_SECONDS", + "ORCH_AGENT_TIMEOUT_OVERRIDES_JSON", + "ORCH_AGENT_TIMEOUT_DEVELOPER_S", + "ORCH_AGENT_TIMEOUT_REVIEWER_S", + "ORCH_REAPER_MAX_RUNNING_S", + ): + monkeypatch.delenv(name, raising=False) + s = Settings() + max_budget = max( + s.agent_timeout_seconds, + s.agent_timeout_developer_s, + s.agent_timeout_reviewer_s, + ) + assert s.reaper_max_running_s > max_budget + s.agent_kill_grace_seconds + # Concrete shipped numbers (ADR-001 D4): 5400 > 3600 + 20 = 3620. + assert (max_budget, s.agent_kill_grace_seconds, s.reaper_max_running_s) == ( + 3600, + 20, + 5400, + ) + + def test_tc08_resolved_max_is_developer(self, monkeypatch): + """TC-08/AC-5: the max resolved per-role budget is the developer budget.""" + monkeypatch.setattr(settings, "agent_timeout_seconds", 1800) + monkeypatch.setattr(settings, "agent_timeout_overrides_json", "") + monkeypatch.setattr(settings, "agent_timeout_developer_s", 3600) + monkeypatch.setattr(settings, "agent_timeout_reviewer_s", 3000) + monkeypatch.setattr(settings, "agent_kill_grace_seconds", 20) + monkeypatch.setattr(settings, "reaper_max_running_s", 5400) + roles = ["analyst", "architect", "developer", "reviewer", "tester", "deployer"] + max_timeout = max(AgentLauncher._resolve_timeout(r) for r in roles) + assert max_timeout == 3600 + assert settings.reaper_max_running_s > max_timeout + settings.agent_kill_grace_seconds + + +# --------------------------------------------------------------------------- # +# TC-09 — tracker stage line shows model+effort on a timeout-killed run (FR-4) +# --------------------------------------------------------------------------- # +class TestTrackerTimeoutVisibility: + """A -9 run still renders '· · ' because both are launch-stamped. + + The stage line takes its model/effort from the LAST run of the agent + (stage_runs[-1] in _stage_line). When that last run is a timeout-kill (-9), its + launch-stamped values are exactly what the operator sees — the whole point of + stamping at launch. Without the stamp the -9 row would carry model=NULL and the + line would drop the model suffix (the AC-6 FAIL condition). + """ + + def _mk_task(self, stage="done", wid="ORCH-109"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) " + "VALUES (?,?,?,?,?,?)", + ("p1", wid, "orchestrator", "feature/ORCH-109-x", stage, "t"), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + def _add_run(self, tid, *, exit_code, model, effort, started, finished): + conn = get_db() + conn.execute( + "INSERT INTO agent_runs (task_id, agent, started_at, finished_at, " + "exit_code, input_tokens, output_tokens, cost_usd, model, effort) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + (tid, "developer", started, finished, exit_code, 10, 5, 0.0, model, effort), + ) + conn.commit() + conn.close() + + def test_tc09_killed_run_renders_model_effort(self): + """TC-09/AC-6: the -9 (last) developer run's launch-stamped model+effort show.""" + tid = self._mk_task(stage="done") + # run 1: succeeded (opens the ✅ stage line) — DIFFERENT model so we can prove + # the displayed value comes from the killed run, not this one. + self._add_run( + tid, + exit_code=0, + model="claude-sonnet-4-6", + effort="high", + started="2026-06-14 09:00:00", + finished="2026-06-14 09:20:00", + ) + # run 2: timeout-killed (-9), the LAST run -> _stage_line reads its row. + self._add_run( + tid, + exit_code=-9, + model="tokenator/claude-opus-4-8", + effort="xhigh", + started="2026-06-14 09:25:00", + finished="2026-06-14 09:55:00", + ) + + text = N.render_task_tracker(tid) + line = [ln for ln in text.splitlines() if ln.startswith("✅ Разработка")][0] + # model NOT null: the killed run's launch-stamped opus-4-8 · xhigh is shown. + assert line.rstrip().endswith("opus-4-8 · xhigh") + assert "sonnet" not in line # the displayed value is the -9 run's, not run 1's + + def test_tc09_unstamped_killed_run_drops_model_suffix(self): + """AC-6 FAIL-guard: a -9 run with model=NULL would omit the suffix (negative).""" + tid = self._mk_task(stage="done") + self._add_run( + tid, + exit_code=0, + model="tokenator/claude-opus-4-8", + effort="xhigh", + started="2026-06-14 09:00:00", + finished="2026-06-14 09:20:00", + ) + # killed run WITHOUT a launch stamp (the pre-ORCH-109 bug): model+effort NULL. + self._add_run( + tid, + exit_code=-9, + model=None, + effort=None, + started="2026-06-14 09:25:00", + finished="2026-06-14 09:55:00", + ) + text = N.render_task_tracker(tid) + line = [ln for ln in text.splitlines() if ln.startswith("✅ Разработка")][0] + # No launch stamp -> the model/effort suffix is dropped (cost shown without model). + assert "opus-4-8" not in line + assert "xhigh" not in line + + +# --------------------------------------------------------------------------- # +# TC-10 — in-flight model visibility via get_running_agents (NFR-6) +# --------------------------------------------------------------------------- # +class TestInflightModelVisibility: + """get_running_agents exposes the launch-stamped model for a RUNNING job.""" + + def test_tc10_running_job_exposes_model(self): + """TC-10/AC-7: /metrics & /queue see the model before the run finishes.""" + conn = get_db() + cur = conn.execute( + "INSERT INTO agent_runs (task_id, agent, model, effort) VALUES (?,?,?,?)", + (1, "developer", "claude-opus-4-8", "xhigh"), + ) + rid = cur.lastrowid + conn.execute( + "INSERT INTO jobs (agent, repo, status, run_id, started_at) " + "VALUES (?,?,?,?,datetime('now'))", + ("developer", "orchestrator", "running", rid), + ) + conn.commit() + conn.close() + + rows = get_running_agents() + assert len(rows) == 1 + assert rows[0]["model"] == "claude-opus-4-8" # non-null in-flight + assert rows[0]["effort"] == "xhigh" + + +# --------------------------------------------------------------------------- # +# TC-11 — anti-salvage: a timeout-killed run does NOT advance the stage (FR-5) +# --------------------------------------------------------------------------- # +class TestAntiSalvage: + """Advancement is gated by `if exit_code == 0`; a -9 run is routed to retry/fail.""" + + class _Proc: + def __init__(self, code): + self._code = code + + def wait(self): + return self._code + + def _seed_run(self, agent="developer"): + conn = get_db() + cur = conn.execute( + "INSERT INTO agent_runs (task_id, agent) VALUES (?,?)", (1, agent) + ) + rid = cur.lastrowid + conn.commit() + conn.close() + return rid + + def _drive(self, monkeypatch, exit_code, agent="developer", job_id=7): + import src.agents.launcher as L + + calls = {"advance": [], "finalize": []} + monkeypatch.setattr( + L.AgentLauncher, + "_try_advance_stage", + lambda self, *a, **k: calls["advance"].append(a), + ) + monkeypatch.setattr( + L.AgentLauncher, + "_finalize_job", + lambda self, *a, **k: calls["finalize"].append(a), + ) + monkeypatch.setattr( + L.AgentLauncher, "_post_usage_comments", lambda self, *a, **k: None + ) + monkeypatch.setattr(L, "notify_agent_finished", lambda *a, **k: None) + monkeypatch.setattr(L, "get_worktree_path", lambda r, b: "/nonexistent/path") + + # git status returns "no changes" so the commit/push branch is skipped. + class _R: + stdout = "" + stderr = "" + returncode = 0 + + monkeypatch.setattr(L.subprocess, "run", lambda *a, **k: _R()) + + rid = self._seed_run(agent) + L.AgentLauncher()._monitor_agent( + self._Proc(exit_code), + rid, + agent, + "orchestrator", + "feature/ORCH-109-x", + output_path=None, + log_fh=None, + job_id=job_id, + ) + return calls + + def test_tc11_killed_developer_run_does_not_advance(self, monkeypatch): + """TC-11/AC-8: a developer run killed (-9) does not auto-advance the stage.""" + calls = self._drive(monkeypatch, exit_code=-9, agent="developer") + assert calls["advance"] == [] # NO auto-advance on -9 + assert len(calls["finalize"]) == 1 # routed to retry/fail finalizer instead + + def test_tc11_killed_reviewer_run_does_not_advance(self, monkeypatch): + """TC-11/AC-8: same guard for the reviewer role (review -> testing).""" + calls = self._drive(monkeypatch, exit_code=-9, agent="reviewer") + assert calls["advance"] == [] + + def test_tc11_clean_exit_advances(self, monkeypatch): + """Positive control: a clean exit (0) DOES reach _try_advance_stage.""" + calls = self._drive(monkeypatch, exit_code=0, agent="developer") + assert len(calls["advance"]) == 1 + + +# --------------------------------------------------------------------------- # +# TC-12 — contracts & schema untouched (NFR-1 / NFR-3, AC-9) +# --------------------------------------------------------------------------- # +class TestContractsUnchanged: + """ORCH-109 lives entirely outside the stage-machine / QG / schema layers.""" + + def test_tc12_stage_transitions_unchanged(self): + """AC-9: no new edge / sink introduced.""" + from src.stages import STAGE_TRANSITIONS + + assert set(STAGE_TRANSITIONS) == { + "created", + "analysis", + "architecture", + "development", + "review", + "testing", + "deploy-staging", + "deploy", + "done", + "cancelled", + } + + def test_tc12_agent_runs_model_effort_columns_preexist(self): + """AC-9: model/effort are PRE-EXISTING columns; ORCH-109 adds no migration.""" + conn = get_db() + cols = [r[1] for r in conn.execute("PRAGMA table_info(agent_runs)").fetchall()] + conn.close() + assert "model" in cols + assert "effort" in cols + + def test_tc12_qg_checks_registry_present(self): + """AC-9: the QG registry is untouched (timeout/telemetry is not a gate).""" + from src.qg.checks import QG_CHECKS + + assert "check_ci_green" in QG_CHECKS + assert "check_reviewer_verdict" in QG_CHECKS