---
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 (канон дефолтов)
