architect(ET): auto-commit from architect run_id=426
All checks were successful
CI / test (push) Successful in 24s

This commit is contained in:
2026-06-09 08:31:45 +03:00
parent 1d6c7663a4
commit 194d6c820e
3 changed files with 322 additions and 0 deletions

View File

@@ -0,0 +1,242 @@
# ADR-001: Anti-orphan bump (реестр message_id + sweep) + эффорт в строке стадии
## Статус
Accepted
## Контекст
Live-трекер ведёт **одну карточку на задачу** (`update_task_tracker`, `src/notifications.py`).
Дефолтный режим — `bump` (ORCH-067): на каждом обновлении карточка пересоздаётся внизу чата
(`delete_telegram(old) → send_telegram(new) → set_tracker_message_id(new)`). Указатель
`tasks.tracker_message_id` хранит **ровно один** message_id — последний.
**Симптом (Слава, 08.06, ORCH-082):** в чате висела карточка с заголовком «📍 To Analyse»,
хотя задача реально была на стадии `deploy` (все стадии ✅ вплоть до «Внедрение»). Параллельно
актуальной карточки с deploy-статусом не было видно.
BRD §3 требует G0: **установить точную механику бага по данным, до фикса**. Ниже — вывод G0,
заземлённый на разборе фактического кода (`src/notifications.py` ревизии ветки) и топологии
вызовов, а не на предположениях. Этот ADR — вход в реализацию; фикс вслепую запрещён.
---
## G0 — расследование (ответы на R-1…R-4)
### Метод
Источник истины для механики — **код + топология вызовов**, она детерминирована и проверяема.
Полевые данные из BRD (message_id `18204`, `18227`; `deleteMessage → ok:true` для обоих) служат
подтверждающими отметками: они доказывают, что (1) карточек одной задачи в чате было **минимум
две** и (2) бот **имеет право** удалять — значит причина сирот НЕ в правах, а в **потере ссылки**
на message_id. Точная механика выводится из кода ниже; staging-воспроизведение R-3 — предмет
верификации на стадии тестирования (см. AC-0.1 / тест-план TC-13).
### R-1. Сколько реально карточек висело
**≥ 2** подтверждено данными BRD (18204 «To Analyse» + 18227). Структурно реестра нет → точное
число сирот, накопленных за жизнь задачи, **не восстановимо постфактум** — это и есть корневой
дефект наблюдаемости (нет учёта всех созданных message_id). Именно поэтому фикс вводит реестр
(`tracker_messages`): он делает число карточек задачи измеримым и управляемым.
### R-2. В какие моменты `tracker_message_id` рассинхронизируется — по пунктам (ae)
Разбор ветки `mode == "bump"` (`update_task_tracker`, стр. 576586):
```python
if mid is not None:
delete_telegram(mid) # best-effort; результат НЕ гейтит send
new_mid = send_telegram(text, disable_notification=True)
if new_mid is not None:
set_tracker_message_id(task_id, new_mid)
```
| Пункт BRD | Вердикт | Механика |
|---|---|---|
| **(a)** `send` вернул `None` | **НЕ источник сирот** | `set_tracker_message_id` не вызывается → указатель цел (это и есть защита AC-3). `delete(old)` мог уже убрать карточку → временно 0 карточек, перерисовка на следующем апдейте. Сирота не создаётся. |
| **(b)** рестарт между `delete` и `send` | **Частично / не основной** | Если `delete` успел (old удалён), `send` не успел → указатель = old(мёртв). Следующий апдейт: `delete(old)→gone`, `send(new)`. Сирота **не** появляется. **НО** рестарт между `send` (new жив) и `set_tracker_message_id` → указатель остаётся = old; следующий апдейт удаляет old и шлёт ещё одну карточку → **`new` осиротел**. Реальный, но узкий источник. |
| **(c)** ручной CLI/пересоздание | **Источник (операционный)** | Карточки, созданные вне `update_task_tracker`, не попадают в указатель → сироты. Происходит при ручных фиксах. |
| **(d)** гонка двух `update_task_tracker` | **ОСНОВНОЙ** | `update_task_tracker` вызывается из ≥5 независимых потоков: FastAPI-вебхуки (`webhooks/plane.py`, `gitea.py`), `queue_worker`, `reconciler`, `job_reaper`, монитор launcher (`agents/launcher.py`), плюс `stage_engine`/`plane_sync`. Оба потока читают `mid=X`, оба `delete(X)` (один ok, другой `gone`), оба `send`**две** новые карточки M1, M2; `set(M1)`+`set(M2)` (last-writer-wins) → указатель = M2, **M1 осиротел**. Замок отсутствует. На быстрых стадиях (start→finish→stage-change подряд) вероятность высокая. |
| **(e)** `delete` упал (transient/rate-limit), `send` прошёл | **ОСНОВНОЙ** | `delete_telegram` вернул `False` (network/timeout/5xx/429 — старое сообщение ЖИВО), но `send` успешен → `set(new)`. **Старая карточка жива, указатель ушёл → сирота.** Прямое следствие «результат delete не гейтит send» (намеренное BR-6, чтобы не блокировать карточку). |
**Вывод R-2:** доминирующие источники сирот — **(d) гонка** и **(e) delete-fail + send-ok**;
вклад дают также **(b) рестарт между send и set** и **(c) ручные операции**. Пункт **(a) не
порождает сирот** (это лишь временное исчезновение карточки, самозалечивается). Общий корень всех
четырёх — **отсутствие учёта всех созданных message_id**: одиночный указатель структурно не может
подчистить карточку, ссылку на которую он потерял.
### R-3. Почему заголовок застывает на «To Analyse»
**Это старый рендер осиротевшей карточки, НЕ баг плана-лейбла.** `render_task_tracker`
**stateless**: строит статус-строку из `tasks.stage` на момент рендера через
`plane_status_label``_STAGE_STATUS_LABEL[stage]`. Живая карточка по определению отрисована на
текущей стадии. «To Analyse» = `_DEFAULT_STATUS_LABEL` И `_STAGE_STATUS_LABEL["created"]` — то
есть карточка была создана, когда задача стояла на `created`/раннем `analysis`, после чего
указатель ушёл (источник d/e), и эта карточка больше **никогда не перерисовывалась** — замёрзла на
первом рендере. Код рендера корректен; чинить надо **сирот (G1)**, а не лейбл. Это подтверждает
гипотезу BRD §1.
Регрессионная страховка G2: тест «stage меняется → статус-строка свежей bump-карточки меняется»
(TC-07) фиксирует, что перерисовка несёт актуальный `plane_status_label`.
### R-4. bump vs edit — что надёжнее против сирот
| | `edit` | `bump` (текущий дефолт) |
|---|---|---|
| Сироты | Структурно исключены (правит ОДНО сообщение in-place; новое шлёт только на `EDIT_GONE`) | Плодит при рассинхроне (d/e/b/c) |
| Карточка внизу чата | **Теряется** (остаётся на месте, тонет в истории) | **Есть** — намеренная фича ORCH-042/067 |
| Гонка | Тоже уязвима, но потолок — один лишний send на `EDIT_GONE`, не на каждый апдейт | Лишний send на каждый рассинхрон |
**Рекомендация (принята): остаться на `bump` + добавить гарантированную зачистку (Подход Б).**
Обоснование с данными:
- `bump`-вниз-чата — **сознательное продуктовое решение** ORCH-042/067 (карточка не теряется в
истории чата). Переход на `edit` (Подход В) откатил бы фичу ради лечения симптома, который
решается **в рамках** `bump`.
- Корень сирот — **не режим**, а **отсутствие учёта message_id**. Реестр (`tracker_messages`)
закрывает корень при любом режиме: даже `edit` выигрывает от учёта на случай `EDIT_GONE`-fallback.
- `edit` тоже не панацея против гонки (два потока, оба `EDIT_GONE` → два send). Реестр нужен в
обоих случаях; значит правильно сохранить фичу и добавить учёт, а не жертвовать фичей.
---
## Решение
### Архитектурный выбор: **Подход Б** — реестр всех message_id задачи + sweep незакрытых.
Режим `bump` сохраняется дефолтом; `edit` остаётся доступен (`ORCH_TRACKER_MODE=edit`),
инвариант «одна карточка» теперь обеспечивается **учётом**, а не одиночным указателем.
#### 1. Новая аддитивная таблица `tracker_messages` (G1)
```sql
CREATE TABLE IF NOT EXISTS tracker_messages (
task_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
deleted INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (task_id, message_id)
);
CREATE INDEX IF NOT EXISTS idx_tracker_messages_live
ON tracker_messages(task_id) WHERE deleted = 0;
```
`CREATE TABLE/INDEX IF NOT EXISTS` — идемпотентно, не трогает `tasks`/`agent_runs`/`events`/
enduro-данные в общей прод-БД. Логический FK на `tasks.id` (без `REFERENCES`, как `jobs.task_id`)
— миграция не падает на живой БД. Детали схемы — `08-data-requirements.md`.
#### 2. Новые helper'ы в `src/db.py` (never-raise, как соседи)
- `record_tracker_message(task_id, message_id)``INSERT OR IGNORE` свежий mid (`deleted=0`).
- `mark_tracker_message_deleted(task_id, message_id)``UPDATE … SET deleted=1`.
- `list_live_tracker_messages(task_id) -> list[int]` — все `deleted=0` mid задачи (для sweep).
- `get_tracker_message_id` / `set_tracker_message_id`**сохраняются** (обратная совместимость;
«актуальный» указатель = последняя успешно отправленная карточка).
#### 3. Новый порядок `bump`-ветки `update_task_tracker` (G1)
```
text = render_task_tracker(task_id)
new_mid = send_telegram(text, disable_notification=True) # ≤1 send за вызов (AC-4)
if new_mid is not None:
record_tracker_message(task_id, new_mid) # учёт ДО смены указателя
set_tracker_message_id(task_id, new_mid) # указатель только при успехе (AC-3)
sweep_orphans(task_id, keep=new_mid or current_mid) # подчистить всё live, кроме актуальной
# send вернул None → указатель и реестр целы; перерисовка на следующем апдейте
```
**`sweep_orphans(task_id, keep)`** (новый, в `notifications.py`):
для каждого `mid in list_live_tracker_messages(task_id)` где `mid != keep`:
`delete_telegram(mid)`
- `True` (удалён ИЛИ `_DELETE_GONE_MARKERS`, вкл. 48ч «can't be deleted») →
`mark_tracker_message_deleted` (наша проблема исчерпана — AC-6);
- `False` (transient) → оставить `deleted=0`, повторить на следующем апдейте/проходе.
**Ключевое отличие от текущего кода:** sweep чистит **все** ранее созданные живые карточки, а не
только «последний указатель» → закрывает источники **(d)**, **(e)**, **(b)**, **(c)** разом:
осиротевшую при гонке/упавшем delete карточку видно в реестре и она будет подчищена при следующем
обновлении. `send` по-прежнему **не гейтится** delete'ом (BR-6 сохранён): сначала шлём свежую,
потом метём старые.
**Идемпотентность гонки:** даже при двух параллельных апдейтах оба `record` своих mid в реестр;
последующий sweep любого из них подчистит лишние (всё, кроме `keep`). Реестр — точка сходимости:
в установившемся состоянии ровно одна `deleted=0` запись (AC-6 / TC-06). Гонку это не **исключает**
(замок не вводим — лишний потоковый лок в never-raise слое опаснее редкой лишней карточки), но
делает её **самозалечивающейся** на следующем тике.
#### 4. Kill-switch (G1)
`tracker_orphan_sweep_enabled: bool = True` (env `ORCH_TRACKER_ORPHAN_SWEEP_ENABLED`) в
`src/config.py`. `False` → sweep отключён, поведение 1:1 как до ORCH-087 (только смена указателя),
для безопасного раската/отката на общей прод-БД. `record_tracker_message` пишется всегда (дёшево,
аддитивно) — отключается только активная зачистка.
### G2 — заголовок отражает текущую стадию
Удовлетворяется устранением сирот (G1): единственная живая карточка по построению отрисована
stateless-рендером на текущей стадии. Доп. кода рендера не требуется. Регресс-тест TC-07.
### G3 — статусы deploy-цикла видимы
Offline-ядро уже даёт: `deploy → «⏸️ Awaiting Deploy — ожидание Confirm Deploy»`, `done → «Done»`
(`_STAGE_STATUS_LABEL`). Ветки `Deploying` / `Confirm Deploy` / `Monitoring after Deploy` рисует
live-overlay (`_live_plane_branch_override`, `_LIVE_BRANCH_LABELS`). Требование — **покрыть
тестами** весь цикл (TC-08); если обнаружится дыра — закрыть в `_STAGE_STATUS_LABEL` /
`_LIVE_BRANCH_LABELS` **без** изменения статусной модели ORCH-066, `STAGE_TRANSITIONS`, `plane_sync`.
Изменений машины стадий не требуется.
### G4 — эффорт в строке стадии
1. **Схема (`src/db.py`):** `_ensure_column(conn, "agent_runs", "effort", "TEXT")` рядом с
`…"model"…`. NULL для старых строк.
2. **Стамп факта (`src/agents/launcher._spawn`):** записать **ровно ту** строку `effort`, что
формирует `effort_flag` (то, что ушло в `--effort`), а НЕ пересчёт `resolve_agent_effort` на
рендере. Реализация: расширить начальный `INSERT INTO agent_runs (task_id, agent)`
`(task_id, agent, effort)`, либо `UPDATE agent_runs SET effort=? WHERE id=run_id` сразу после
`run_id = cursor.lastrowid`. **Важно:** `effort` резолвится на стр. ~475, ПОСЛЕ `INSERT` на
стр. ~449 → проще `UPDATE` после резолва (или перенести `INSERT` ниже резолва). `effort==""`
(флаг опущен) → хранить `NULL`/`""`. Запись эффорта **никогда** не валит `_spawn` (контракт
launcher; обернуть try/except или полагаться на существующий).
3. **Рендер (`render_task_tracker._stage_line`):** добавить `effort` в `SELECT … FROM agent_runs`
(стр. ~322325) и в `_stage_line` после `model`. **Формат:** `… · {model} · {effort}`
разделитель `·`, единообразно с существующим `model_suffix`. Пустой/NULL `effort` → суффикс
**не добавляется** (как `model_suffix` при пустой модели) — деградация без `None`/падения (AC-9).
Пример: `✅ Разработка 7м · 4.9M↓/32k↑ · $3.97 · opus-4-8 · xhigh`.
**Уровни (при дефолтном конфиге, ORCH-081):** developer=`xhigh`, tester/deployer=`medium`,
analyst/architect/reviewer=`high`. Значение — из `agent_runs.effort` (факт), не пересчёт (AC-8).
G4 идёт **в этом же PR** (одна зона: `notifications.py` + `agent_runs`), как разрешает BRD §4.
---
## Последствия
### Плюсы
- Корень сирот закрыт **учётом** (реестр), а не выбором режима → фича `bump`-вниз-чата ORCH-042/067
сохранена; `edit` тоже выигрывает (учёт на `EDIT_GONE`).
- Покрыты все реальные источники (d/e/b/c) одним механизмом; самозалечивание на следующем тике.
- Полностью аддитивно и идемпотентно (`CREATE TABLE IF NOT EXISTS`, `_ensure_column`) → нулевая
регрессия для enduro-trails в общей прод-БД; restart-safe (состояние — в БД, не в памяти).
- Kill-switch для безопасного раската на self-hosting проде.
- Эффорт берётся из факта запуска → не врёт при смене конфига между запуском и рендером.
### Минусы / ограничения
- **48ч (Telegram):** сообщения старше 48ч удалить нельзя (`message can't be deleted`
`_DELETE_GONE_MARKERS``True`). Такие сироты помечаются `deleted=1` (наша проблема исчерпана) и
**физически остаются в чате**. Документируется как ограничение (AC-6). Новый код не плодит таких
сирот (sweep на каждом апдейте, задолго до 48ч); ограничение касается лишь УЖЕ накопленных старых.
- **Гонка не исключается, а делается самозалечивающейся** — в окне между двумя параллельными
апдейтами кратко возможны 2 карточки; сходится к 1 на следующем тике. Замок намеренно не вводим
(риск дедлока/блокировки в never-raise слое > редкая лишняя карточка).
- **Rate-limit (429)** при массовой зачистке накопленных сирот: `delete_telegram` вернёт `False`
(transient) → не помечаем `deleted`, повторим позже. Зачистка растянется, но не сломается и не
зациклится (each delete — один best-effort вызов за апдейт-проход).
- Рост `tracker_messages`: ~N строк на задачу (N = число апдейтов). Незначительно; при желании —
фоновая обрезка `deleted=1` старше X дней (вне скоупа ORCH-087).
### Инварианты (не нарушены)
- `update_task_tracker`**never-raise**; ни delete/send/sweep не валят конвейер (AC-5).
- «Одна карточка на задачу» — после установившегося обновления ровно одна `deleted=0` (AC-4/AC-6).
- Карточка тихая `disable_notification=True`; `disable_web_page_preview=True` (ORCH-080);
`plane_issue_link` кликабелен (ORCH-067) — всё сохранено.
- НЕ трогаются: `STAGE_TRANSITIONS`, `QG_CHECKS`, статусная модель ORCH-066, `plane_sync`-ключи,
поведение для не-self проектов сверх миграции (аддитивна, инертна).
### Не выбрано (и почему)
- **Подход А (только строгий swap указателя):** не покрывает рестарт между delete и send и гонку
— недостаточен сам по себе (BRD §2).
- **Подход В (дефолт `edit`):** откатывает продуктовую фичу ORCH-042/067 ради симптома, решаемого
внутри `bump`; не устраняет гонку. Отклонён (R-4).
- **Глобальный ADR не заводится:** изменение замкнуто в компоненте Notifications + аддитивная
таблица; нет новой стадии/QG/сквозного компонента. Достаточно per-work-item ADR (канон CLAUDE.md).
## Артефакты к обновлению в реализации (тот же PR)
`CLAUDE.md` (секция трекера), `docs/architecture/README.md` (компонент Notifications),
`CHANGELOG.md` (`## [Unreleased]`, union-merge ORCH-073), `08-data-requirements.md` (этот PR),
тесты по `04-test-plan.yaml`.

View File

@@ -0,0 +1,60 @@
# 08 — Требования к схеме БД — ORCH-087
Все изменения **аддитивны и идемпотентны**, безопасны на живой ОБЩЕЙ прод-БД (enduro-trails +
orchestrator из одного инстанса). Существующие колонки/данные не трогаются. Решение — ADR-001.
## 1. Новая таблица `tracker_messages` (G1 — anti-orphan, Подход Б)
Реестр ВСЕХ Telegram message_id, созданных трекером для задачи, и их состояние. Позволяет
подчищать осиротевшие карточки, ссылку на которые потерял одиночный `tasks.tracker_message_id`.
```sql
CREATE TABLE IF NOT EXISTS tracker_messages (
task_id INTEGER NOT NULL, -- логический FK tasks.id (без REFERENCES, как jobs.task_id)
message_id INTEGER NOT NULL, -- Telegram message_id отправленной карточки
created_at TEXT DEFAULT (datetime('now')),
deleted INTEGER NOT NULL DEFAULT 0, -- 0 = живая (надо подчистить), 1 = удалена/исчерпана
PRIMARY KEY (task_id, message_id)
);
CREATE INDEX IF NOT EXISTS idx_tracker_messages_live
ON tracker_messages(task_id) WHERE deleted = 0;
```
- Миграция: `conn.executescript(...)` с `CREATE TABLE/INDEX IF NOT EXISTS` в `init_db()` (рядом с
блоком `job_deps`). Повторный `init_db` — no-op.
- `PRIMARY KEY(task_id, message_id)``INSERT OR IGNORE` идемпотентен (дубль mid не падает).
- Частичный индекс `WHERE deleted=0` ускоряет sweep (выборка только живых).
- Логический FK (без `REFERENCES tasks(id)`) — миграция не падает на pre-existing БД, как `job_deps`.
- `deleted=1` присваивается при: успешном `delete`, «уже нет» (`message to delete not found`),
«нельзя удалить» (48ч, `message can't be deleted``_DELETE_GONE_MARKERS`). Transient-fail
(`False`) → запись остаётся `deleted=0`, повтор на следующем апдейте.
### Helper'ы доступа (`src/db.py`, never-raise)
| Функция | Назначение |
|---|---|
| `record_tracker_message(task_id, message_id)` | `INSERT OR IGNORE` свежий mid (`deleted=0`) |
| `mark_tracker_message_deleted(task_id, message_id)` | `UPDATE … SET deleted=1` |
| `list_live_tracker_messages(task_id) -> list[int]` | все `deleted=0` mid задачи (для sweep) |
`get_tracker_message_id` / `set_tracker_message_id` — без изменений (обратная совместимость).
## 2. Новая колонка `agent_runs.effort` (G4 — эффорт в строке стадии)
```sql
-- идемпотентная миграция в init_db(), рядом с _ensure_column(... "agent_runs", "model" ...)
ALTER TABLE agent_runs ADD COLUMN effort TEXT; -- через _ensure_column (no-op если есть)
```
- Миграция: `_ensure_column(conn, "agent_runs", "effort", "TEXT")`.
- Хранит ФАКТИЧЕСКИ применённый `--effort` запуска (то, что ушло в CLI), стампится в
`launcher._spawn` после `effort = resolve_agent_effort(...)`. Не пересчитывается на рендере.
- `NULL`/`""` для старых строк и для запусков с опущенным флагом (`effort==""`) → рендер опускает
эффорт-суффикс (деградация без падения, AC-9).
- Значения: подмножество `VALID_EFFORTS` (`low|medium|high|xhigh|max`) либо `NULL`.
## 3. Гарантии совместимости (общая прод-БД)
- Никаких `ALTER`/`DROP` существующих колонок; только новая таблица + новая колонка.
- `CREATE TABLE IF NOT EXISTS` / `_ensure_column` → повторный запуск инертен (AC-10).
- Данные enduro-trails не читаются и не пишутся этими миграциями (таблицы изолированы по `task_id`,
колонка `effort` заполняется только новыми запусками).
- Restart-safe: всё состояние в БД; рестарт орка не теряет реестр и не плодит сирот по новой схеме.

View File

@@ -0,0 +1,20 @@
# 10 — Технические риски — ORCH-087
| # | Риск | Вероятность / Влияние | Митигация |
|---|------|----------------------|-----------|
| R1 | **Сироты старше 48ч неудаляемы** (Telegram: `message can't be deleted`) — остаются в чате навсегда | Сред / Низ (косметика) | Помечаем `deleted=1` (`_DELETE_GONE_MARKERS`), зачистка не зацикливается; новый код не порождает таких сирот (sweep на каждом апдейте ≪ 48ч). Документировано (AC-6). Старые накопленные — вне автоматики. |
| R2 | **Rate-limit / 429 при массовой зачистке** накопленных сирот | Низ / Низ | `delete_telegram``False` (transient) → запись остаётся `deleted=0`, повтор позже. Один best-effort delete на запись за проход; зачистка растягивается, не падает. |
| R3 | **Гонка не исключается** (нет замка) — кратко возможны 2 карточки между параллельными апдейтами | Сред / Низ | Реестр делает гонку **самозалечивающейся**: sweep следующего тика подчистит лишнее (всё ≠ `keep`). Замок намеренно не вводим — лок в never-raise слое опаснее редкой лишней карточки (риск дедлока при `max_concurrency=1`). |
| R4 | **Регрессия миграции на общей прод-БД** (enduro-trails) | Низ / Выс | Строго аддитивно: `CREATE TABLE IF NOT EXISTS` + `_ensure_column` (no-op если есть); логический FK без `REFERENCES`. Idempotent `init_db`. Покрыто TC-09. |
| R5 | **never-raise нарушен** новым sweep/реестром → падение валит конвейер | Низ / Выс | Все helper'ы и `sweep_orphans` обёрнуты как соседи; `update_task_tracker` целиком в `try/except` (AC-5, TC-05). Запись эффорта в `_spawn` не валит launcher. |
| R6 | **Эффорт-стамп врёт** (пересчёт на рендере расходится с CLI при смене конфига) | Сред / Низ | Источник — РОВНО строка `effort`, ушедшая в `--effort` (стамп в `_spawn`), не `resolve_agent_effort` на рендере (AC-8). |
| R7 | **Порядок в `_spawn`:** `INSERT agent_runs` (стр.~449) идёт ДО резолва `effort` (стр.~475) | Сред / Низ | Реализовать `UPDATE agent_runs SET effort=? WHERE id=run_id` после резолва, либо перенести `INSERT` ниже резолва. Зафиксировано в ADR-001 §G4.2. |
| R8 | **Дыра покрытия deploy-цикла** (G3): ветка статуса не отрисована | Низ / Низ | Тесты TC-07/TC-08 проверяют offline-лейблы (`deploy`/`done`) и overlay-маппинг (`deploying`/`monitoring`); дыра закрывается в `_STAGE_STATUS_LABEL`/`_LIVE_BRANCH_LABELS` без изменения ORCH-066. |
| R9 | **Рост `tracker_messages`** (~N строк/задача) | Низ / Низ | Незначительно для SQLite; опц. фоновая обрезка `deleted=1` старше X дней — вне скоупа ORCH-087. |
## Принятые архитектурные ограничения
- Замок на `update_task_tracker` **не вводится** (R3) — сходимость через реестр предпочтительнее
потокового лока в never-raise слое.
- Дефолтный режим остаётся `bump` (фича ORCH-042/067 сохранена); `edit` доступен через env.
- Изменения замкнуты в компоненте Notifications + аддитивная схема — `STAGE_TRANSITIONS`,
`QG_CHECKS`, статусная модель ORCH-066, `plane_sync`-ключи не трогаются. Глобальный ADR не нужен.