---
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.
