architect(ET): auto-commit from architect run_id=764
All checks were successful
CI / test (push) Successful in 1m12s

This commit is contained in:
2026-06-16 19:17:43 +03:00
parent fef5ba15d5
commit de4f067655
6 changed files with 544 additions and 4 deletions

View File

@@ -0,0 +1,300 @@
---
work_item: ORCH-124
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-16
model_used: claude-opus-4-8
---
# ADR-001: Serial-gate «пауза без блокировки» — явный per-task park-сигнал (ORCH-124)
Work Item: **ORCH-124** · Repo: **orchestrator** (self-hosting) · Стадия: architecture
Связь: BRD `01-brd.md`, ТЗ `02-trz.md`, AC `03-acceptance-criteria.md`, данные `08-data-requirements.md`, риски `10-tech-risks.md`.
Сквозная регистрация: `docs/architecture/adr/adr-0051-serial-gate-pause-without-blocking.md`.
## Статус
Proposed
---
## Контекст
Баг (метка `Bug`, эскалирован в full-cycle — `escalate: full-cycle`, ADR-001 D5 ORCH-019): по сути
**архитектурный** дефект семантики serial-gate, требующий ADR (выбор механизма «паузы» + разрешение
конфликта с анти-stale-base ORCH-088).
**Симптом (инцидент ORCH-116/ORCH-123, установленный факт).** Задачу-предшественника ORCH-116
поставили на паузу (перевели в Plane Blocked/Backlog), чтобы пропустить вперёд срочный фикс ORCH-123.
`serial_gate` **по-прежнему считал ORCH-116 активной** и держал analyst-job ORCH-123 в `queued` — срочный
фикс не стартовал, пока ORCH-116 формально не `done`/`cancelled`.
**Причина (верифицировано в коде).** `serial_gate.py` определяет «активную задачу репо» **исключительно
по машинной стадии** `tasks.stage NOT IN ('done','cancelled')` в трёх точках:
- `build_claim_clause()` — горячий SQL-фрагмент в `db.claim_next_job` (`src/serial_gate.py:274-278`):
`EXISTS (SELECT 1 FROM tasks t2 WHERE t2.repo = jobs.repo AND t2.id < jobs.task_id AND
t2.stage NOT IN ('done','cancelled'))`;
- `repo_has_active_task()` — Python-зеркало (`src/serial_gate.py:117-127`);
- `_per_repo_snapshot()` — выбор `active_task` для `GET /queue` (`src/serial_gate.py:340-344`).
Plane-статусы Backlog/Blocked/Needs-Input — **слой B (индикация), ORCH-066** — **не меняют `tasks.stage`
(слой A)**. Сеттеры `set_issue_blocked`/`set_issue_needs_input` делают только `PATCH` Plane-статуса; у
таблицы `tasks` **нет колонки статуса** (`src/reconciler.py:322`: «`tasks` has no status column, so the
live Plane state is the source of truth»). ⇒ для serial-gate приостановленная задача неотличима от активно
исполняемой: её стадия вне `{done,cancelled}` ⇒ она «активна» ⇒ держит FIFO закрытым для всех более
поздних analyst-job того же репо.
**Прецедент, который НЕ переиспользуем.** `reconciler` уже различает Blocked/Needs-Input
(`_is_blocked_or_needs_input`, ORCH-060 Guard 2) — но **сетевым** запросом Plane. `serial_gate.build_claim_clause`
врезан в `claim_next_job`**offline hot-path** — и сетевого вызова позволить **не может** (NFR-2). Это и
есть центральное расхождение: сигнал паузы есть в Plane, но недоступен горячему SQL гейта.
**Нужен** явный, durable, **DB-резолвимый** признак «пауза», который горячий SQL читает локально, при этом
**не регрессирует** анти-stale-base ORCH-088 (R-1) и не ломает гармонизированный терминал `{done,cancelled}`
(ORCH-090 / adr-0026, NFR-4).
---
## Решение
### Сводка
Вводится **явный per-task park-сигнал** — аддитивная нуллабельная колонка **`tasks.paused_at TEXT`**
(NULL = не на паузе; non-NULL = поставлена оператором на паузу) — и **новая ортогональная ось
планировщика «пауза»**, отделённая от оси «терминальность». «Активная задача» для serial-gate
переопределяется как **`не терминальна И не на паузе`** во всех трёх точках; терминал `{done,cancelled}`
в `serial_gate`/`task_deps`/`stages.py` остаётся **байт-в-байт**. Намерение паузы задаётся явными
операторскими эндпоинтами `POST /serial-gate/pause|resume` (по образцу `POST /serial-gate/unfreeze`).
Анти-stale-base при возобновлении обеспечивают **существующие** механизмы (отложенный срез ветки ORCH-088
+ pre-merge `auto_rebase_onto_main` под merge-lease ORCH-026/093 + merge-gate re-test ORCH-110) — **новой
rebase-машинерии не вводится**. Аддитивно, под независимым под-флагом, never-raise, restart-safe.
`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict / схемы существующих таблиц —
**не трогаются** (правка планировщика очереди, не Quality Gate).
### D1 — Механизм: явный per-task pause-флаг (а не release-on-status / task_deps) (FR-3, BR-2)
**Решение: явный durable DB-сигнал «park» на уровне задачи**, инициируемый оператором через API, а **не**
маппинг Plane-статуса и **не** `task_deps`.
Обоснование выбора (см. «Альтернативы» для отклонённых):
- **Чистое намерение, отличное от cancel и от kill-switch** (BR-2): park ≠ терминал (`cancelled`),
≠ глобальное выключение гейта (`serial_gate_enabled=False`).
- **DB-резолвимо и offline** (NFR-2): сигнал — колонка локальной БД, читается горячим SQL без сети.
- **Не перегружает Plane-статус** (ORCH-066/059): pause НЕ управляется сменой Plane-статуса. Оператор
может **дополнительно** перевести карточку в Blocked для индикации, но это косметика — гейт ею не
управляется. Это прямое следование решению ORCH-088 D4 (снятие freeze Plane-жестом отвергнуто как
анти-паттерн ORCH-059).
- **Durable/идемпотентно/restart-safe** (BR-2/R-3): колонка переживает рестарт; не зависит от доставки
webhook (потерянный webhook не рассинхронит сигнал).
### D2 — Хранилище: аддитивная колонка `tasks.paused_at` (а не отдельная таблица) (NFR-3)
**Решение: нуллабельная колонка `tasks.paused_at TEXT`** через `_ensure_column` — паттерн уже
существующих per-task durable-сигналов `tasks.cancelled_at` / `tasks.cancel_requested_at` / `tasks.track`
(`src/db.py:141-149`). NULL = не на паузе; ISO-таймстамп = на паузе (момент постановки, наблюдаемость).
Почему **колонка**, а не таблица по образцу `repo_freeze`:
- Пауза — **per-task** сигнал (кардинальность 1:1 с задачей), в отличие от `repo_freeze` (per-**repo**,
append-only журнал истории заморозок).
- Горячий SQL `build_claim_clause` уже сканирует `tasks t2` — добавление `AND t2.paused_at IS NULL`
внутрь существующего `EXISTS`-подзапроса — **минимальная, offline, index-дружественная** правка без
лишнего JOIN/EXISTS. Таблица потребовала бы доп. подзапрос в горячем пути.
- Схемы существующих таблиц (`tasks`/`jobs`/`job_deps`/`repo_freeze`) не меняются деструктивно; миграция —
идемпотентный `_ensure_column` (no-op на уже мигрированной БД), безопасна на общей прод-БД (enduro не
затронут). Детали — `08-data-requirements.md`.
### D3 — Пауза — ортогональная ось; терминал `{done,cancelled}` не трогается (NFR-4, FR-6 — критично)
**Решение: «активность» для serial-gate = `не терминальна И не на паузе`; терминал остаётся
`{done,cancelled}` без изменений.**
Это явная, задокументированная дивергенция, которую требует NFR-4. Две независимые оси:
| Ось | Предикат | Где используется | Меняется ORCH-124? |
|-----|----------|------------------|--------------------|
| **Терминальность** | `stage IN ('done','cancelled')` | `serial_gate` + `task_deps` + `stages.py` (adr-0026) | **НЕТ — байт-в-байт** |
| **Пауза (новая)** | `paused_at IS NOT NULL` | **только** FIFO «active» предикат `serial_gate` | да (аддитивно) |
Следствия (закрывает R-4 и FR-6):
- **serial-gate** «активная задача» = `stage NOT IN ('done','cancelled') AND paused_at IS NULL`. Пауза
выводит предшественника из FIFO-учёта serial-gate.
- **task_deps** НЕ трогается: остаётся чисто терминальным (`stage NOT IN ('done','cancelled')`). Явно
объявленная зависимость (`job_deps`) на **приостановленную** задачу **по-прежнему блокирует** зависимый
job — пауза НЕ обходит `task_deps` (FR-6/AC-5). Пауза («пропустите меня в FIFO») и dependency
(«B реально нужен результат A») — разные оси.
- **stages.py** `STAGE_TRANSITIONS` не трогается: пауза — не стадия и не ребро (нет нового стока/перехода).
### D4 — Три точки serial-gate правятся согласованно (FR-1, FR-2)
Один предикат «активна» во всех трёх точках (анти-дрейф: одинаковый ответ на одинаковый вход), под
под-флагом паузы (D6):
1. **`build_claim_clause()`** — в `active_clause` добавить терм `AND t2.paused_at IS NULL` (только когда
слой паузы включён; иначе фрагмент строится байт-в-байт как ORCH-088/090):
```sql
EXISTS (SELECT 1 FROM tasks t2
WHERE t2.repo = jobs.repo AND t2.id < jobs.task_id
AND t2.stage NOT IN ('done','cancelled')
{pause_term}) -- pause_term = " AND t2.paused_at IS NULL" | ""
```
Инварианты сохранены: гейт только для `jobs.agent='analyst'`; FIFO `t2.id < jobs.task_id` (R-7,
нет самоблокировки); job'ы активной задачи проходят.
2. **`repo_has_active_task()`** — добавить `AND paused_at IS NULL` (под тем же под-флагом).
3. **`_per_repo_snapshot()`** — выбор `active_task` исключает приостановленные (`AND paused_at IS NULL`),
и отдельно перечисляет приостановленные (D5).
### D5 — Наблюдаемость: причина ожидания + список paused (FR-5, BR-4, AC-6)
`_per_repo_snapshot` расширяется **аддитивно** (существующие ключи `active_task`/`waiting`/`frozen`/
`frozen_reason`/`frozen_at` — байт-в-байт BC):
- `active_task` больше **не** показывает приостановленную задачу (D4.3).
- Новый ключ `paused: [{work_item_id, stage, paused_at}, …]` — приостановленные незавершённые задачи репо
(видимы, но не как `active_task`).
- Для каждого `waiting`-job добавляется `reason` — причина, по которой job НЕ claimable, с приоритетом:
**`freeze`** (активен `repo_freeze`) → **`dependency`** (незавершённая `task_deps` для task этого job)
→ **`active-task`** (есть более ранняя **не-приостановленная** незавершённая задача) → **`null`**
(claimable). Категория `paused-predecessor` из ТЗ FR-5 — наблюдается через ключ `paused` (приостановленный
предшественник по дизайну **не** блокирует ⇒ не является причиной ожидания после фикса).
### D6 — Условность: независимый под-флаг `serial_gate_pause_enabled` (FR-7, NFR-3)
По образцу `serial_gate_freeze_enabled` (`src/config.py:1006`) — независимый тумблер для поэтапного раската
и обратимости:
- `serial_gate_pause_enabled: bool = True` (env `ORCH_SERIAL_GATE_PAUSE_ENABLED`). Дефолт `True` безопасен:
пока ни одна задача не на паузе (`paused_at` всегда NULL), предикат `AND t2.paused_at IS NULL` всегда
истинен ⇒ поведение **идентично** ORCH-088/090 ⇒ **истинный no-op** до явной операторской паузы (enduro
не затронут). Постановка на паузу возможна только через явный эндпоинт (D7).
- `False` ⇒ pause-терм **опускается** из SQL, эндпоинты pause/resume — no-op-предупреждение; serial-gate
ведёт себя **байт-в-байт** как ORCH-088/090 (осознанный rollback-режим — возврат к текущему багу, не
дефолт).
- Хелпер `serial_gate._pause_layer_enabled()` (never-raise, зеркало `_freeze_layer_enabled`).
- **Область** — переиспользует `serial_gate_repos` (пауза — уточнение того же гейта; новый `*_repos`
**не** вводится — принцип минимума конфигурации). Под-флаг паузы независим от `serial_gate_freeze_enabled`,
но подчинён kill-switch `serial_gate_enabled` (при выключенном гейте паузы нет смысла).
### D7 — Операторские эндпоинты pause/resume (FR-3, BR-5, AC-3, AC-10)
По образцу `POST /serial-gate/unfreeze` (`src/main.py:350-376`), never-raise, с Telegram-подтверждением:
- **`POST /serial-gate/pause?work_item=<id>`** → `db.set_task_paused(task_id)` (`paused_at=datetime('now')`,
идемпотентно). Применимо к **нетерминальной** задаче (паузить `done`/`cancelled` — no-op + явный ответ).
Возвращает `{ok, work_item, task_id, paused_at}`.
- **`POST /serial-gate/resume?work_item=<id>`** → `db.clear_task_paused(task_id)` (`paused_at=NULL`).
Возобновлённая задача снова участвует в serial-gate (AC-10): если ещё в `analysis` без ветки —
ре-входит в FIFO с отложенным срезом ветки; если уже материализована — держит гейт как активная,
её свежесть гарантирует merge-gate (D8). Возвращает `{ok, work_item, task_id, paused_at: null}`.
- DB-хелперы `db.set_task_paused`/`db.clear_task_paused`/`db.is_task_paused` (по образцу
`set_task_track`/`get_task_track`, `src/db.py:740-757`). never-raise.
- Освобождение гейта — **только** по этому явному durable намерению; эвристического само-распаузивания
нет (AC-3, R-2).
### D8 — Анти-stale-base при возобновлении: переиспользуем существующие механизмы (FR-4, R-1 — критично)
**Решение: пауза «демотирует» задачу в FIFO; свежесть базы при возобновлении гарантируют УЖЕ
существующие механизмы — новой rebase-машинерии НЕ вводится.**
Два случая возобновления:
1. **Пауза, пока задача ещё в `analysis` с queued analyst-job и НЕматериализованной веткой** (отложенный
срез ORCH-088 D1): при resume срез ветки происходит на момент claim analyst-job
(`launcher._materialize_deferred_branch`) от **тогда-актуального** `origin/main` — который уже содержит
любого успешника, смерженного за время паузы. База **структурно свежая** ⇒ stale-base невозможна.
2. **Пауза после материализации ветки** (development/review/testing/deploy-staging): ветка уже срезана от
более раннего `main`. За время паузы успешник может смержиться ⇒ `main` уходит вперёд. При resume, когда
задача дойдёт до merge-gate (`deploy-staging → deploy`), **существующий безусловный pre-merge
`auto_rebase_onto_main` под merge-lease** (ORCH-026/088/093) перебазирует ветку на актуальный `main`, а
**merge-gate re-test** (ORCH-110) перепрогоняет сюиту на перебазированном HEAD. Свежесть обеспечивается
на merge, **не обходится**.
Итог (разрешение конфликта R-1): пауза меняет **только порядок FIFO** (кто держит гейт), но **не**
контракт свежести на merge. Нормально исполняемая задача (`paused_at IS NULL`) по-прежнему держит гейт ⇒
анти-stale-base для нормального случая (BR-3/AC-2) не регрессирует. Порядок merge при «B обгоняет
паузнутую A» = B, затем A (A ребейзится на B) — ровно намерение оператора. Проверяемо тестом по контракту
ADR (AC-4).
### D9 — never-raise и сохранённые fail-directions (NFR-1, AC-9)
- **`build_claim_clause`** остаётся **fail-OPEN**: pause-терм строится **внутри** существующего
`try/except`; любая ошибка (в т.ч. в pause-подвыражении) → `""` → claim без гейта (не заклинить очередь
всех проектов, AC-8). Направление не инвертируется.
- **Freeze** остаётся **fail-CLOSED** (`is_repo_frozen`, AC-9) — pause-логика его не касается.
- Pause-зеркало/снапшот/мутаторы never-raise → консервативная деградация (на ошибке чтения паузы в зеркале
— «не на паузе», т.е. задача считается активной = гейт скорее закрыт = анти-stale-base-safe).
---
## Точки врезки (для разработчика)
| Файл | Изменение |
|------|-----------|
| `src/db.py` | `_ensure_column(conn, "tasks", "paused_at", "TEXT")` (D2); хелперы `set_task_paused`/`clear_task_paused`/`is_task_paused` (D7) |
| `src/serial_gate.py` | `_pause_layer_enabled()` (D6); pause-терм в `build_claim_clause` (D4.1); `AND paused_at IS NULL` в `repo_has_active_task` (D4.2) и `_per_repo_snapshot` (D4.3); ключ `paused` + `reason` в снапшоте (D5). Маркер `ORCH-124` рядом с `ORCH-088`/`ORCH-090` |
| `src/config.py` | `serial_gate_pause_enabled: bool = True` (D6) |
| `src/main.py` | `POST /serial-gate/pause`, `POST /serial-gate/resume` (D7); блок `serial_gate` в `GET /queue` уже зовёт `snapshot()` (D5 — расширение прозрачно) |
| `tests/test_orch124_serial_gate_pause.py` | **новый** — AC-1 регресс инцидента (красный до фикса, зелёный после), AC-2…AC-10 |
| `docs/architecture/README.md`, `internals.md`, `CHANGELOG.md` | обновить раздел serial-gate + ось паузы (golden source) |
`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи / `start_pipeline` / `launcher`
deferred-branch / merge-gate / схемы существующих таблиц — **не трогаются**.
---
## Альтернативы (отклонены)
- **Release-on-status (Plane Blocked/Backlog → DB-сигнал через webhook)** — отвергнуто: перегружает
Plane-статус управлением конвейером (нарушает слой A/B ORCH-066 и анти-паттерн ORCH-059, ровно как
ORCH-088 D4 отверг снятие freeze Plane-жестом); хрупко к потере webhook (R-3); намерение не доступно
offline hot-path (NFR-2).
- **Переиспользовать `task_deps`** — отвергнуто: `task_deps` моделирует «B ждёт A», не умеет выразить
«A на паузе, остальных пропустить» (обратное направление). Кроме того, пауза НЕ должна обходить
объявленную зависимость (FR-6) — это разные оси (D3).
- **Отдельная таблица `task_hold` (по образцу `repo_freeze`)** — отвергнуто: пауза per-task 1:1; колонка
минимальнее и не требует JOIN в горячем SQL (D2). `repo_freeze` — таблица, т.к. per-repo append-only журнал.
- **Реюз `repo_freeze` для паузы** — отвергнуто: freeze замораживает **весь репо** (блокирует всех
успешников) — противоположность «пропустить срочного успешника».
- **Расширить терминал `{done,cancelled,paused}`** — отвергнуто: пауза не терминальна; это сломало бы
`task_deps`/`stages.py` (NFR-4). Пауза — ортогональная ось, не терминальное состояние (D3).
- **Новая rebase-машинерия при resume** — отвергнуто как избыточное: существующие отложенный срез +
merge-gate rebase/re-test уже покрывают свежесть (D8).
---
## Последствия
### Плюсы
- **+** Закрывает инцидент ORCH-116/ORCH-123 (AC-1): срочный фикс стартует поверх паузнутого предшественника.
- **+** Чистое, явное, durable намерение паузы, отличное от cancel и kill-switch (BR-2); webhook-независимо
(R-3); offline hot-path (NFR-2).
- **+** Терминал `{done,cancelled}` и `task_deps`/`stages.py` — байт-в-байт (NFR-4); пауза НЕ обходит
freeze/dependency (FR-6).
- **+** Анти-stale-base (ORCH-088) не регрессирует — нормальная задача держит гейт; resume опирается на
существующие отложенный срез + merge-gate rebase/re-test (D8, AC-2/AC-4).
- **+** Переиспользует проверенные паттерны (`cancelled_at`-колонка, `unfreeze`-эндпоинт, leaf never-raise,
`/queue`-снапшот). `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схемы существующих таблиц — без изменений.
- **+** Истинный no-op для enduro при дефолтном флаге (пауза не выставлена) и байт-в-байт откат при флаге off.
### Минусы / ограничения
- **** Пауза — операторское действие через API (не Plane-жест). Митигейшн: симметрично существующему
`unfreeze`; задокументировано в README + Telegram-подтверждение; оператор может дополнительно перевести
карточку в Blocked для индикации.
- **** «Залипшая пауза» при невнимании оператора (resume забыт) → задача навсегда вне гейта. Митигейшн:
наблюдаемость (`paused` в `GET /queue`); resume идемпотентен; намерение durable, не теряется (R-2).
- **** Горячий SQL serial-gate теперь несёт 3 маркера (`ORCH-088`/`ORCH-090`/`ORCH-124`) ⇒ сводный
сквозной ADR `adr-0051` (анти-археология, TRACEABILITY.md).
### Откат
Полный откат — `ORCH_SERIAL_GATE_PAUSE_ENABLED=false` (serial-gate 1:1 как ORCH-088/090; pause-терм
опущен, эндпоинты no-op). Колонка `tasks.paused_at` инертна при выключенном под-флаге. Глубже —
`serial_gate_enabled=false` (весь гейт инертен, как до ORCH-088).
---
## Ссылки
- BRD: `docs/work-items/ORCH-124/01-brd.md` · ТЗ: `02-trz.md` · Acceptance: `03-acceptance-criteria.md`
- Данные: `docs/work-items/ORCH-124/08-data-requirements.md` · Риски: `10-tech-risks.md`
- Сквозной ADR: `docs/architecture/adr/adr-0051-serial-gate-pause-without-blocking.md`
- Сверено по коду: `src/serial_gate.py` (117-127, 274-278, 340-344), `src/db.py` (claim_next_job
1043-1110, `_ensure_column`/tasks-колонки 141-149, 740-757), `src/main.py` (350-376), `src/config.py`
(1004-1006), `src/reconciler.py:322`
- Базовые решения: adr-0017 (serial-gate ORCH-088), adr-0026 (терминал `{done,cancelled}` ORCH-090),
adr-0015 (task-deps ORCH-026), adr-0027 (merge-актор rebase/retry ORCH-093), adr-0042 (merge-gate
re-test ORCH-110)
</content>
</invoke>

View File

@@ -0,0 +1,54 @@
---
work_item: ORCH-124
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-16
model_used: claude-opus-4-8
---
# 08 — Требования к данным: ORCH-124 — per-task park-сигнал serial-gate
Work Item: **ORCH-124** · Repo: **orchestrator** · Стадия: architecture
> When-applicable / информационный (гейтом не парсится).
## Изменения схемы БД
**Одна аддитивная нуллабельная колонка** на существующей таблице `tasks` (никаких новых таблиц):
| Таблица | Колонка | Тип / дефолт | Семантика |
|---------|---------|--------------|-----------|
| `tasks` | `paused_at` | `TEXT` (по умолчанию отсутствует → `NULL`) | `NULL` = не на паузе; ISO-таймстамп (`datetime('now')`) = задача поставлена оператором на паузу (park) |
Миграция — идемпотентный `_ensure_column(conn, "tasks", "paused_at", "TEXT")` в `init_db()`, ровно по
образцу `tasks.cancelled_at` / `tasks.cancel_requested_at` / `tasks.track` (`src/db.py:141-149`). На уже
мигрированной БД — no-op.
**Индекс не требуется.** Горячий SQL `build_claim_clause` сканирует `tasks t2` уже сегодня (по `repo`/`id`);
терм `AND t2.paused_at IS NULL` — дополнительный фильтр в существующем `EXISTS`-подзапросе, не новый план
доступа. Кардинальность `tasks` per-repo мала; добавление индекса — преждевременная оптимизация (принцип
минимума).
## Новые/изменённые сущности
- **`tasks.paused_at`** — единственное durable хранилище намерения паузы. Запись — `db.set_task_paused`
(`paused_at=datetime('now')`); сброс`db.clear_task_paused` (`paused_at=NULL`); чтение —
`db.is_task_paused` и SQL-предикат serial-gate. Все хелперы never-raise.
- **Инвариант оси:** `paused_at`**ортогональная** ось «пауза», независимая от оси «терминальность»
(`stage IN ('done','cancelled')`). serial-gate «активна» = `stage NOT IN ('done','cancelled') AND
paused_at IS NULL`. `task_deps`/`stages.py` колонку `paused_at` **не читают** (терминал не трогается,
NFR-4).
- **Существующие таблицы** (`jobs` / `job_deps` / `repo_freeze` / `agent_runs`) — без изменений.
## Совместимость данных / миграции
- **Аддитивно и идемпотентно:** `_ensure_column` — no-op на уже-мигрированной БД; новая колонка
дефолтит в `NULL` для всех существующих строк ⇒ все текущие задачи считаются «не на паузе» ⇒ поведение
до ORCH-124 сохраняется до первой явной операторской паузы.
- **Restart-safe / durable:** значение в БД переживает рестарт процесса/контейнера (BR-2, R-3).
- **Общая прод-БД (self-hosting):** колонка добавляется на общей БД; при дефолтном `serial_gate_pause_enabled`
и отсутствии паузнутых задач — нулевая регрессия для enduro (`paused_at` везде `NULL`).
- **Откат:** колонка инертна при `ORCH_SERIAL_GATE_PAUSE_ENABLED=false` (pause-терм опускается из SQL).
Колонку можно оставить (безвредна); деструктивный drop не требуется и не рекомендуется на прод-БД.
</content>

View File

@@ -0,0 +1,41 @@
---
work_item: ORCH-124
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-16
model_used: claude-opus-4-8
---
# 10 — Технические риски: ORCH-124 — serial-gate «пауза без блокировки»
Work Item: **ORCH-124** · Repo: **orchestrator** · Стадия: architecture
> Информационный (гейтом не парсится). Перечисляет риски реализации и митигейшн; покрывает R-1…R-5 из BRD §8.
## Реестр рисков
| ID | Риск | Вер. | Влия. | Митигейшн |
|----|------|------|-------|-----------|
| TR-1 (= R-1, ключевой) | **Пауза vs анти-stale-base ORCH-088.** Успешник срезает ветку от `main` без кода паузнутого предшественника; при возобновлении предшественника возможна stale-база/затирание. | Сред. | Выс. | **D8:** новой rebase-машинерии нет — свежесть гарантируют существующие механизмы. Паузнутая-в-`analysis` задача при resume режет ветку отложенно (ORCH-088) от свежего `origin/main`. Материализованная — ребейзится на merge-gate (`auto_rebase_onto_main` под merge-lease ORCH-026/093) + re-test (ORCH-110). Нормальная задача (`paused_at IS NULL`) по-прежнему держит гейт (BR-3/AC-2). Тест AC-4. |
| TR-2 (= R-2) | **Неявное/случайное освобождение гейта** (баг в детекте намерения) ослабит сериализацию для всех. | Низ. | Выс. | Освобождение **только** по явной операторской паузе через эндпоинт (D7); никакого эвристического само-распаузивания (AC-3). Дефолтный флаг безопасен (no-op без явной паузы). Тест AC-3. |
| TR-3 (= R-3) | **Рассинхрон Plane-статус ↔ DB-сигнал паузы** (потерянный webhook оставит сигнал устаревшим). | Низ. | Сред. | Механизм НЕ опирается на webhook/Plane-статус (D1): сигнал — durable колонка `tasks.paused_at`, пишется прямым операторским вызовом, идемпотентен, переживает рестарт. Plane-статус — только косметическая индикация. |
| TR-4 (= R-4) | **Регрессия гармонизированного терминала `{done,cancelled}`** в `task_deps`/`stages.py`. | Низ. | Выс. | **D3:** пауза — отдельная ось; терминал `{done,cancelled}` в `serial_gate`/`task_deps`/`stages.py` байт-в-байт. `task_deps` колонку `paused_at` не читает (паузнутая зависимость по-прежнему блокирует, FR-6/AC-5). Структурный тест AC-8. |
| TR-5 (= R-5) | **Инверсия fail-direction** (ошибка в pause-ветке роняет claim или меняет fail-OPEN/fail-CLOSED). | Низ. | Выс. | **D9:** pause-терм внутри `try/except` `build_claim_clause` → fail-OPEN сохранён; freeze fail-CLOSED не тронут; все pause-функции never-raise. Тест AC-9 (инъекция ошибки → claim не падает). |
| TR-6 | **«Залипшая пауза»** — оператор забыл `resume`, задача навсегда вне FIFO-учёта. | Сред. | Низ. | Наблюдаемость: ключ `paused` + `reason` в `GET /queue` (D5); `resume` идемпотентен; durable сигнал не теряется. Операторская гигиена (как «вечный freeze» ORCH-088). |
| TR-7 | **Дрейф трёх точек** serial-gate (одна правится, другие нет → расхождение SQL-гейта и снапшота). | Низ. | Сред. | **D4:** один предикат «активна» во всех трёх точках, под одним под-флагом; анти-дрейф-тест (одинаковый ответ на одинаковый вход). |
| TR-8 | **Миграция колонки на общей прод-БД** (self-hosting) затронет enduro. | Низ. | Сред. | Идемпотентный `_ensure_column`, дефолт `NULL` (паттерн `cancelled_at`/`track`); при дефолтном флаге и отсутствии паузнутых задач — нулевая регрессия (08-data-requirements). |
## Сводный вывод
Доминирующий класс — **семантический конфликт паузы с анти-stale-base (TR-1)**, разрешённый
**переиспользованием существующих** механизмов свежести (D8), без новой машинерии. Остальные риски —
стандартные для leaf-расширения serial-gate (fail-direction, дрейф точек, миграция), покрыты паттернами
ORCH-088/090. Изменение **аддитивно, под независимым под-флагом, never-raise**, без правки
`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/терминала/схем существующих таблиц.
**Эскалация `arch:major-change` не требуется** (нет новой стадии/компонента/QG/смены БД — аддитивная
правка планировщика внутри существующего компонента serial-gate). Возврат в анализ не требуется (ТЗ
удовлетворяется без нарушения принципов архитектуры). Остаточный риск для прод-конвейера (self-hosting) —
**низкий**: дефолтное поведение — истинный no-op до явной операторской паузы; полный откат — один env-флаг.
</content>