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>
27 KiB
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_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):
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'; FIFOt2.id < jobs.task_id(R-7, нет самоблокировки); job'ы активной задачи проходят.repo_has_active_task()— добавитьAND paused_at IS NULL(под тем же под-флагом)._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(envORCH_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-switchserial_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-машинерии НЕ вводится.
Два случая возобновления:
- Пауза, пока задача ещё в
analysisс queued analyst-job и НЕматериализованной веткой (отложенный срез ORCH-088 D1): при resume срез ветки происходит на момент claim analyst-job (launcher._materialize_deferred_branch) от тогда-актуальногоorigin/main— который уже содержит любого успешника, смерженного за время паузы. База структурно свежая ⇒ stale-base невозможна. - Пауза после материализации ветки (development/review/testing/deploy-staging): ветка уже срезана от
более раннего
main. За время паузы успешник может смержиться ⇒mainуходит вперёд. При resume, когда задача дойдёт до merge-gate (deploy-staging → deploy), существующий безусловный pre-mergeauto_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) ⇒ сводный сквозной ADRadr-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)