Compare commits
15 Commits
c04dba0c0a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fc1d3db505 | |||
|
|
f5c93aa3cc | ||
| 2028b6cb14 | |||
| 8628e609d9 | |||
| 834d8d78b0 | |||
| bc96977eb7 | |||
| b81de1536c | |||
| bbcaa93cff | |||
| 6bd7f9ba84 | |||
| b025e1bdf4 | |||
| 0bb27b7627 | |||
| aa40d530c5 | |||
| f52790004e | |||
| adebb997e6 | |||
|
|
25ce5a22a9 |
28
.env.example
28
.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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 роутятся по типу:
|
||||
|
||||
@@ -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<max`→`queued`, иначе `failed`+Telegram). Атомарный reap-claim (guard `status='running'`) совместим со стартовым `requeue_running_jobs`. Тот же поток периодически делает проактивный реклейм stale/dead merge-lease (см. ниже). never-raise; kill-switch `ORCH_REAPER_ENABLED`; снимок в `GET /queue` (блок `reaper`).
|
||||
- **Reconciler** (`src/reconciler.py`, ORCH-053 — реализовано, [adr-0007](adr/adr-0007-reconciler.md)) — фоновый daemon-поток (паттерн `queue_worker`), стартует/останавливается в `main.lifespan` (после `worker.start()` / перед `worker.stop()`). Реконсилирует рассинхрон «источник истины ≠ стадия задачи» при потерянном webhook. F-1 gate-side (продвигает застрявшую стадию по локальной БД через штатный `advance_stage(..., finished_agent=None)`), F-2 plane-side (опрос Plane API → `handle_*` из `plane.py`), F-3 (БД-fallback `sha→branch` в `handle_ci_status`). Источник истины — гейт/Plane, не событие; идемпотентность (active-job guard + atomic-claim + grace); kill-switch `ORCH_RECONCILE_ENABLED`. `analysis` F-1 не трогает (человеческий гейт). F-1 также пропускает escalated (retry≥лимита) и Blocked/Needs-Input задачи (ORCH-060). Наблюдаемость — блок `reconcile` в `GET /queue`.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
work_item: ORCH-109
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-14
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# adr-0040: Per-role wall-clock бюджеты (developer/reviewer) + launch-time стамп модели
|
||||
|
||||
- **Статус:** proposed
|
||||
- **Дата:** 2026-06-14
|
||||
- **Задача:** ORCH-109
|
||||
- **Детальный ADR:** `docs/work-items/ORCH-109/06-adr/ADR-001-agent-timeout-budgets-and-launch-model-stamp.md`
|
||||
|
||||
## Контекст
|
||||
Инцидент **ORCH-104** вскрыл два глобальных дефекта подсистемы запуска агентов (`src/agents/launcher.py`),
|
||||
затрагивающих **все** репо общего self-hosting-инстанса (orchestrator + enduro-trails):
|
||||
(A) единый wall-clock тайм-аут `agent_timeout_seconds=1800` убивает здоровые тяжёлые роли
|
||||
(`developer` xhigh, `reviewer`), т.к. в проде `agent_timeout_overrides_json` пуст; (B)
|
||||
`agent_runs.model` пишется только постфактум из usage-JSON (`record_usage`, `COALESCE`), а
|
||||
timeout-killed прогон финальный JSON не эмитит → модель остаётся `NULL` именно в момент инцидента,
|
||||
хотя эффорт уже стампится на launch (ORCH-087). Решение меняет два **глобальных per-agent
|
||||
инварианта** (бюджеты тайм-аутов + потолок Tier-3 reaper'а ORCH-065), поэтому регистрируется сквозным
|
||||
ADR, а не только work-item ADR.
|
||||
|
||||
## Решение
|
||||
Две аддитивные правки launcher'а, **без** касания `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/
|
||||
machine-verdict-ключей/схемы БД (колонка `agent_runs.model TEXT` уже существует — миграции нет):
|
||||
|
||||
- **Launch-time стамп модели.** В `_spawn` резолвенная `resolve_agent_model(...)` пишется в
|
||||
`agent_runs.model` рядом со стампом эффорта (объединённый `UPDATE … SET model=?, effort=?`),
|
||||
пустой резолв → `NULL`. Постфактум `record_usage` (`model=COALESCE(?, model)`) остаётся
|
||||
**обогащением**, перестаёт быть единственным источником истины — launch-стамп переживает kill и
|
||||
виден in-flight (`db.get_running_agents` уже отдаёт `model`). never-raise: сбой стампа изолирован,
|
||||
launch не падает.
|
||||
- **Per-role бюджеты через выделенные типизированные config-ключи** (по образцу
|
||||
`agent_model_<role>`/`agent_effort_<role>`): `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.
|
||||
@@ -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) |
|
||||
|
||||
12
docs/work-items/ORCH-105/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-105/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-105
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
7
docs/work-items/ORCH-109/00-business-request.md
Normal file
7
docs/work-items/ORCH-109/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: ORCH: timeout budgets + launch-time model telemetry for developer/reviewer
|
||||
|
||||
Work Item ID: ORCH-109
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
167
docs/work-items/ORCH-109/01-brd.md
Normal file
167
docs/work-items/ORCH-109/01-brd.md
Normal file
@@ -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` (заполняет архитектор).
|
||||
145
docs/work-items/ORCH-109/02-trz.md
Normal file
145
docs/work-items/ORCH-109/02-trz.md
Normal file
@@ -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<max → queued`, иначе `failed` + Telegram — `launcher._monitor_agent`/`queue_worker`/
|
||||
`job_reaper`) реализует это структурно.
|
||||
- **Требование:** анализ подтверждает достаточность существующей гарантии; поведение фиксируется
|
||||
**регресс-тестом**. Отдельный guard в коде добавляется **только если тест выявит разрыв**.
|
||||
- **salvage-режим НЕ вводится** (вне объёма) — задача гарантирует не-продвижение, не возобновление.
|
||||
|
||||
### FR-6 — Документация конфига (BR-6)
|
||||
- Комментарий-паспорт в `config.py` (блок ORCH-7, строки ~115–126) расширяется описанием поднятых
|
||||
бюджетов `developer`/`reviewer` и ссылкой на reaper-инвариант (NFR-4).
|
||||
- `.env.example` несёт соответствующие ключи с дефолтами = боевым значениям (канон ORCH-101).
|
||||
- Сквозная документация (`CLAUDE.md`, `docs/architecture/README.md` — таблица «модель/эффорт по
|
||||
ролям») обновляется в том же PR.
|
||||
|
||||
## 4. Изменения API
|
||||
|
||||
Нет. Ни одного нового/изменённого endpoint'а. `GET /metrics` и `GET /queue` отдают тот же контракт
|
||||
(`schema_version: 1`) — поле `agents[].model` лишь **начинает заполняться** для running-job
|
||||
(аддитивное улучшение данных, не контракта; sidecar обязан толерировать, ORCH-099 NFR-6).
|
||||
|
||||
## 5. Изменения схемы БД
|
||||
|
||||
Нет. Колонка `agent_runs.model` (`TEXT`, NULLABLE) уже существует (`db._ensure_column`, инициализация
|
||||
`init_db`). Никаких `CREATE`/`ALTER`/новых таблиц. Меняется только **момент** и **частота** записи в
|
||||
существующую колонку (launch + опциональный постфактум-enrich).
|
||||
|
||||
## 6. Требования к новым/изменённым QG checks
|
||||
|
||||
Нет. `QG_CHECKS` / `check_*` / `_parse_*` / machine-verdict ключи — не трогаются. Задача целиком вне
|
||||
слоя Quality Gate (подсистема launch/телеметрия/конфиг). FR-5 опирается на **существующий**
|
||||
exit-code-контракт продвижения, не на новый гейт.
|
||||
|
||||
## 7. Совместимость / регресс
|
||||
|
||||
- **Обратная совместимость:** стамп модели аддитивен; при пустом timeout-override поведение
|
||||
байт-в-байт прежнее (NFR-1). Никаких kill-switch не требуется — изменение не вводит новых ветвей
|
||||
риска (стамп модели всегда безопасен; тайм-аут конфигурируем и fail-safe на глобальный дефолт).
|
||||
- **Область раската:** стамп модели — все репо/роли (безопасно). Поднятый тайм-аут — только
|
||||
`developer`/`reviewer` (все репо, т.к. тайм-аут глобален per-agent); прочие роли неизменны.
|
||||
- **Обратимость:** вернуть тайм-аут — снять override-конфиг (откат на 1800s). Launch-стамп модели
|
||||
отката не требует (чистое улучшение телеметрии).
|
||||
- **Кросс-каттинг (NFR-4):** при поднятии бюджета выше `reaper_max_running_s − grace` синхронно
|
||||
поднять `reaper_max_running_s` (ORCH-065). Sanity-тест конфига стережёт инвариант.
|
||||
- **never-raise (NFR-2):** обе правки изолированы; сбой не роняет launch и не падает на старте при
|
||||
плохом env.
|
||||
- **Self-hosting (NFR-5):** ни рестарта прода, ни изменения deploy-пути; общий инстанс безопасен.
|
||||
- **Полный регресс `tests/`** остаётся зелёным; coverage-гейт (ORCH-027) удовлетворён новым
|
||||
тест-файлом (изменения в `src/` минимальны и покрыты).
|
||||
139
docs/work-items/ORCH-109/03-acceptance-criteria.md
Normal file
139
docs/work-items/ORCH-109/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
work_item: ORCH-109
|
||||
stage: analysis
|
||||
author_agent: analyst
|
||||
status: ready-for-review
|
||||
created_at: 2026-06-14
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-109 — timeout budgets + launch-time model telemetry
|
||||
|
||||
Work Item: **ORCH-109** · Repo: **orchestrator** · Стадия: analysis
|
||||
|
||||
Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL**
|
||||
(что считается провалом). Reviewer/tester проверяет их буквально по файлам репозитория и тестам.
|
||||
|
||||
---
|
||||
|
||||
## AC-1 — Модель стампится в `agent_runs.model` в момент launch
|
||||
|
||||
**Условие:** запуск любого агента через `launcher._spawn` записывает резолвенную модель в
|
||||
`agent_runs.model` строки прогона ДО завершения процесса.
|
||||
- **PASS:** после стампа на launch (`UPDATE agent_runs SET model=…`/объединённый с effort)
|
||||
`SELECT model FROM agent_runs WHERE id=<run_id>` возвращает `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`-ряда содержит
|
||||
` · <short_model> · <effort>` (например `· 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 |
|
||||
94
docs/work-items/ORCH-109/04-test-plan.yaml
Normal file
94
docs/work-items/ORCH-109/04-test-plan.yaml
Normal file
@@ -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 рендерит ' · <short_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
|
||||
@@ -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 <!-- Proposed | Accepted | Superseded by ADR-… -->
|
||||
|
||||
## Контекст
|
||||
|
||||
Инцидент **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_<role>`/`agent_effort_<role>`,
|
||||
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_<role>`/`agent_effort_<role>`). Выделенные ключи дают типобезопасность, индивидуальный
|
||||
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 (канон дефолтов)
|
||||
42
docs/work-items/ORCH-109/10-tech-risks.md
Normal file
42
docs/work-items/ORCH-109/10-tech-risks.md
Normal file
@@ -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-*`. Возврат в анализ не нужен —
|
||||
ТЗ удовлетворяется без нарушения принципов архитектуры.
|
||||
119
docs/work-items/ORCH-109/12-review.md
Normal file
119
docs/work-items/ORCH-109/12-review.md
Normal file
@@ -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`.
|
||||
</content>
|
||||
</invoke>
|
||||
82
docs/work-items/ORCH-109/13-test-report.md
Normal file
82
docs/work-items/ORCH-109/13-test-report.md
Normal file
@@ -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> · <effort>` (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`.
|
||||
12
docs/work-items/ORCH-109/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-109/14-deploy-log.md
Normal file
@@ -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.
|
||||
46
docs/work-items/ORCH-109/15-staging-log.md
Normal file
46
docs/work-items/ORCH-109/15-staging-log.md
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
work_item: ORCH-109
|
||||
stage: deploy-staging
|
||||
author_agent: deployer
|
||||
status: success
|
||||
created_at: 2026-06-14
|
||||
model_used: claude-opus-4-8
|
||||
timestamp: 2026-06-14T11:21:00Z
|
||||
base_url: http://localhost:8501
|
||||
mode: stub
|
||||
---
|
||||
|
||||
# Staging Gate Log — ORCH-109
|
||||
|
||||
Canonical staging suite (`scripts/staging_check.py --base-url http://localhost:8501 --mode stub`)
|
||||
executed **inside the `orchestrator-staging` container** (ORCH-048 canonical path). Exit code **0**
|
||||
→ `staging_status: SUCCESS`.
|
||||
|
||||
## Result
|
||||
|
||||
```
|
||||
RESULT: 8/10 checks PASS
|
||||
REAL failed : none
|
||||
SANDBOX_INFRA failed: ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue']
|
||||
```
|
||||
|
||||
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
|
||||
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
|
||||
|
||||
## Per-block detail
|
||||
|
||||
- **Block A (SMOKE):** A1 `/health` 200 ok · A2 `/queue` 200 (counts/max_concurrency/resilience) · A3 `ORCH_STAGING=true` — all PASS.
|
||||
- **Block B (ACCESS):** B4 Plane sandbox accessible · B5 Gitea `orchestrator-sandbox` accessible (push=true) · B6 registry isolation (sandbox present, prod ET/ORCH absent) — all PASS.
|
||||
- **Block C (E2E, stub):** C7 create issue in Plane SANDBOX · C8 trigger pipeline via `/webhook/plane` — PASS. C9a/C9b — **waived** sandbox-infra (SANDBOX bot accounts are not members of the sandbox Plane project, so pipeline steps 6+ are unreachable in the sandbox; not a pipeline regression — ORCH-061).
|
||||
- **CLEANUP:** Plane test issue deleted (HTTP 204); no stray branch.
|
||||
|
||||
## Environment note (observability)
|
||||
|
||||
The canonical path requires `docker exec orchestrator-staging …`. In this run no `docker`/`docker compose`
|
||||
CLI was reachable and the staging container was not running, so the `orchestrator-staging` service was
|
||||
brought up (staging-only, port 8501, real `.env.staging` → sandbox-isolated registry, `ORCH_STAGING=true`)
|
||||
via the Docker Engine API mirroring the compose `orchestrator-staging` spec, the canonical
|
||||
`staging_check.py` was `docker exec`'d inside it, and the container was torn down afterwards to restore
|
||||
the pre-existing state. Prod (8500) was never touched; `.env`/`.env.staging`/`docker-compose.yml`/prod
|
||||
infra were not modified.
|
||||
25
docs/work-items/ORCH-109/17-security-report.md
Normal file
25
docs/work-items/ORCH-109/17-security-report.md
Normal file
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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_<AGENT> >
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
554
tests/test_orch109_timeout_model.py
Normal file
554
tests/test_orch109_timeout_model.py
Normal file
@@ -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 '· <model> · <effort>' 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
|
||||
Reference in New Issue
Block a user