Files
orchestrator/docs/work-items/ORCH-124/06-adr/ADR-001-serial-gate-pause-without-blocking.md
claude-bot 58e5dfe55d
All checks were successful
CI / test (push) Successful in 1m15s
CI / test (pull_request) Successful in 1m12s
docs(serial-gate): sync system showcase + clean stray tags (ORCH-124)
Addresses reviewer REQUEST_CHANGES (run 768) on ORCH-124 — docs-only,
no src/tests touched, fix scope unchanged.

P1: update docs/overview/ showcase for the new serial-gate "pause without
blocking" axis (changed task-routing functionality, ORCH-011/ORCH-079):
- tech-pipeline.md: FIFO exception "pause without blocking" next to freeze
- tech-data-model.md: durable signal tasks.paused_at on the Task row
- tech-observability.md: paused/reason in serial_gate GET /queue block +
  operator endpoints POST /serial-gate/pause|resume

P2: strip leaked tool-call trailing tags (</content>/</invoke>) from 4
golden-source docs of this PR (06-adr/ADR-001, adr-0051,
08-data-requirements.md, 10-tech-risks.md).

CHANGELOG "Доки" bullet extended accordingly. Full suite green (2178 passed);
test_system_docs.py green (machine-checked showcase facts intact).

Refs: ORCH-124
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 21:50:45 +03:00

27 KiB
Raw Blame History

work_item, stage, author_agent, status, created_at, model_used
work_item stage author_agent status created_at model_used
ORCH-124 architecture architect proposed 2026-06-16 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_joboffline 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):
    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)