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