Compare commits

...

8 Commits

Author SHA1 Message Date
ec932264db reviewer(ET): auto-commit from reviewer run_id=768
All checks were successful
CI / test (push) Successful in 1m16s
CI / test (pull_request) Successful in 1m12s
2026-06-16 20:24:55 +03:00
3a1972875f fix(tests): isolate repos_dir in ORCH-123 staging-runner test fixture
All checks were successful
CI / test (push) Successful in 1m13s
CI / test (pull_request) Successful in 1m12s
The deterministic test-runner gate (full `pytest tests/`) failed on
test_orch123_staging_runner_exec.py::test_r2_held_deploy_staging_not_rolled_back
once ORCH-124 reached the testing stage.

Root cause (pre-existing latent regress, surfaced — not introduced — by
ORCH-124): the fixture isolated `worktrees_dir` but not `repos_dir`.
`check_staging_status` falls back to `<repos_dir>/<repo>` (and its
origin/main) when the feature worktree is absent. After ORCH-123 merged,
the real `/repos/orchestrator/docs/work-items/ORCH-123/15-staging-log.md`
(verdict SUCCESS) exists on disk, so the intended-RED staging gate read it
and went green -> advance_stage was called -> the R-2 assertion failed.
Order-dependent: the test passed alone, failed in the full suite.

Fix: isolate `settings.repos_dir` to an empty tmp subdir in the fixture
(mirroring the existing worktrees_dir isolation) so the staging gate is
deterministically "not found" -> red, regardless of suite ordering. The
ORCH-123 R-2 invariant (a held deploy-staging task is never rolled back to
development, adr-0049/ADR-001 D4) is preserved and strengthened — the fix
only restores the test's stated premise. src/** / STAGE_TRANSITIONS /
QG_CHECKS / check_* untouched (test-only change).

Refs: ORCH-124
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 20:12:28 +03:00
test-runner
c7336dd9ea test(ORCH-116): test gate FAIL for ORCH-124
All checks were successful
CI / test (push) Successful in 1m23s
CI / test (pull_request) Successful in 1m15s
2026-06-16 19:51:06 +03:00
7ac83a9731 reviewer(ET): auto-commit from reviewer run_id=766
All checks were successful
CI / test (push) Successful in 1m23s
CI / test (pull_request) Successful in 1m15s
2026-06-16 19:49:23 +03:00
87af857082 fix(serial-gate): pause-without-blocking via per-task park signal (ORCH-124)
All checks were successful
CI / test (push) Successful in 1m12s
CI / test (pull_request) Successful in 1m17s
Fixes incident ORCH-116/ORCH-123: serial_gate defined a repo's "active task"
purely by machine stage (tasks.stage NOT IN ('done','cancelled')). Plane statuses
Backlog/Blocked/Needs-Input (layer-B indication, ORCH-066) do NOT change
tasks.stage (layer A), so a paused predecessor was indistinguishable from an active
one and held the FIFO gate closed against an urgent successor — the urgent fix
could not start until the paused task was formally done.

Introduces an explicit, durable, DB-resolvable per-task "park" signal — additive
nullable column tasks.paused_at (pattern of cancelled_at/track) — and a new
ORTHOGONAL scheduler "pause" axis. The serial-gate "active task" predicate becomes
`stage NOT IN ('done','cancelled') AND paused_at IS NULL` across all three points
(build_claim_clause / repo_has_active_task / _per_repo_snapshot). The terminal set
{done,cancelled} in serial_gate/task_deps/stages.py is byte-for-byte unchanged
(adr-0026 not regressed): task_deps/stages.py do NOT read paused_at, so a paused
declared dependency and an active repo_freeze STILL block (pause never bypasses
them — different axes). Anti-stale-base on resume relies on the existing deferred
branch cut (ORCH-088) + pre-merge auto_rebase_onto_main + merge-gate re-test
(ORCH-026/093/110) — no new rebase machinery.

Additive, under an independent sub-flag, never-raise, restart-safe; hot-claim
fail-OPEN and freeze fail-CLOSED preserved. STAGE_TRANSITIONS / QG_CHECKS / check_*
/ machine-verdict keys / existing table schemas are byte-for-byte untouched (this is
a queue-scheduler + observability change, not a Quality Gate).

- src/db.py: additive tasks.paused_at column (_ensure_column) + set/clear/is helpers
- src/serial_gate.py: _pause_layer_enabled() + pause-term in the 3 points; `paused`
  list + per-job `reason` (freeze>dependency>active-task>null) in the /queue snapshot
- src/config.py + .env.example: serial_gate_pause_enabled (default True = true no-op)
- src/main.py: POST /serial-gate/pause|resume?work_item=<id> (by образцу unfreeze)
- tests/test_orch124_serial_gate_pause.py: TC-01 mandatory incident regress + TC-02..15
- CHANGELOG.md: [Unreleased] entry

ADR: docs/work-items/ORCH-124/06-adr/ADR-001-serial-gate-pause-without-blocking.md
Cross-cutting: docs/architecture/adr/adr-0051-serial-gate-pause-without-blocking.md

Refs: ORCH-124

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 19:35:55 +03:00
de4f067655 architect(ET): auto-commit from architect run_id=764
All checks were successful
CI / test (push) Successful in 1m12s
2026-06-16 19:17:43 +03:00
fef5ba15d5 analyst(ET): auto-commit from analyst run_id=763
All checks were successful
CI / test (push) Successful in 1m9s
2026-06-16 17:56:23 +03:00
569abee5f2 docs: init ORCH-124 business request
All checks were successful
CI / test (push) Successful in 1m14s
2026-06-16 17:24:43 +03:00
22 changed files with 2004 additions and 12 deletions

View File

@@ -230,9 +230,15 @@ ORCH_TASK_DEPS_SOURCE=db
# SERIAL_GATE_ENABLED=false -> claim AND start_pipeline are 1:1 as before ORCH-088.
# SERIAL_GATE_REPOS (CSV) -> scope; EMPTY = ALL repos (not self-hosting-only).
# SERIAL_GATE_FREEZE_ENABLED=false -> the rollback-freeze layer is off (not set/read).
# SERIAL_GATE_PAUSE_ENABLED (ORCH-124) -> per-task "park" axis. true (default) -> a
# task with tasks.paused_at NOT NULL (POST /serial-gate/pause?work_item=<id>) is
# excluded from the "active task" predicate so an URGENT successor may overtake a
# paused predecessor. TRUE no-op until an operator pauses a task. false -> pause-term
# omitted, serial-gate byte-for-byte ORCH-088/090. Scope reuses SERIAL_GATE_REPOS.
ORCH_SERIAL_GATE_ENABLED=true
ORCH_SERIAL_GATE_REPOS=
ORCH_SERIAL_GATE_FREEZE_ENABLED=true
ORCH_SERIAL_GATE_PAUSE_ENABLED=true
# ORCH-090: STOP-status task cancellation (stop active agent + full progress reset)
# and the relaunch-hole close. A dedicated Plane "STOP" status (logical key `stop`,
# fail-closed: absent from _DEFAULT_STATES, so a board without the status -> no-op)

View File

@@ -1,4 +1,4 @@
Work item: ORCH-116
Work item: ORCH-124
Repo: orchestrator
Branch: feature/ORCH-116-orch-replace-llm-tester-with-d
Branch: feature/ORCH-124-bug-serial-gate-treats-backlog
Stage: development

View File

@@ -3,6 +3,18 @@
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
## [Unreleased]
- **Serial-gate «пауза без блокировки» — явный per-task park-сигнал** (ORCH-124, `fix`): багфикс (метка `Bug`, эскалирован в full-cycle) инцидента **ORCH-116/ORCH-123**. `serial_gate` определял «активную задачу репо» **исключительно по машинной стадии** `tasks.stage NOT IN ('done','cancelled')`, а Plane-статусы Backlog/Blocked/Needs-Input (слой B индикации, ORCH-066) **не меняют `tasks.stage`** (слой A) ⇒ приостановленный предшественник был неотличим от активного и держал FIFO-гейт закрытым против срочного успешника (ORCH-116 поставлен на паузу, чтобы пропустить фикс ORCH-123 — фикс не стартовал, пока ORCH-116 формально не `done`). У оператора не было чистого механизма «пауза без блокировки», отдельного от cancel (терминал) и от глобального выключения гейта. **Инвариант:** правка **планировщика очереди** (claim) и наблюдаемости, **не** Quality Gate — `STAGE_TRANSITIONS` / состав `QG_CHECKS` / семантика и имена `check_*` / machine-verdict ключи (`verdict:`/`result:`/`deploy_status:`/`staging_status:`/`security_status:`) / схемы существующих таблиц — **байт-в-байт не тронуты**. Аддитивно, под независимым под-флагом, never-raise, restart-safe, fail-OPEN на hot-claim сохранён. ADR: `docs/work-items/ORCH-124/06-adr/ADR-001-serial-gate-pause-without-blocking.md`, сквозной `docs/architecture/adr/adr-0051-serial-gate-pause-without-blocking.md`.
- **Механизм (D1):** явный durable DB-сигнал «park» на уровне задачи, инициируемый оператором через API — **не** маппинг Plane-статуса (перегружал бы слой A/B ORCH-066 / анти-паттерн ORCH-059) и **не** `task_deps` (моделирует обратное направление «B ждёт A»). Чистое намерение, отличное от cancel и от kill-switch; DB-резолвимо, offline, webhook-независимо (потерянный webhook не рассинхронит сигнал).
- **Хранилище (D2):** аддитивная нуллабельная колонка `tasks.paused_at TEXT` через `_ensure_column` (паттерн `tasks.cancelled_at`/`cancel_requested_at`/`track`; `src/db.py`) — NULL = не на паузе; ISO-таймстамп = поставлена оператором на паузу. На уже-мигрированной БД — no-op; все существующие строки дефолтят в NULL ⇒ поведение до ORCH-124 до первой явной паузы (enduro не затронут на общей прод-БД). Хелперы `db.set_task_paused`/`clear_task_paused`/`is_task_paused` (never-raise; `is_task_paused` на ошибке → «не на паузе» = задача активна = гейт скорее закрыт = анти-stale-base-safe).
- **Ортогональная ось (D3, критично):** «активность» для serial-gate = `stage NOT IN ('done','cancelled') AND paused_at IS NULL`; **терминал `{done,cancelled}` остаётся байт-в-байт** в `serial_gate`/`task_deps`/`stages.py` (adr-0026 не регрессирует). `task_deps`/`stages.py` колонку `paused_at` **НЕ читают** ⇒ паузнутая объявленная зависимость (`job_deps`) и `repo_freeze` **по-прежнему блокируют** claim (пауза их **не** обходит — разные оси: freeze = весь репо, dependency = конкретная пара, пауза = «пропустите меня в FIFO»).
- **Три точки согласованно (D4, анти-дрейф):** один предикат «активна» под под-флагом — терм `AND t2.paused_at IS NULL` внутри существующего `EXISTS`-подзапроса `build_claim_clause` (горячий offline SQL, без лишнего JOIN), `AND paused_at IS NULL` в `repo_has_active_task` и в выборе `active_task` `_per_repo_snapshot` (`src/serial_gate.py`). Помечено маркером `ORCH-124` рядом с `ORCH-088`/`ORCH-090`.
- **Операторские эндпоинты (D7):** `POST /serial-gate/pause?work_item=<id>` (стамп `paused_at`; терминальная/неизвестная задача → no-op-ответ; под-флаг off → no-op-предупреждение) и `POST /serial-gate/resume?work_item=<id>` (сброс `paused_at` → задача снова участвует в гейте; идемпотентно) — по образцу `POST /serial-gate/unfreeze`, never-raise, с Telegram-подтверждением (`src/main.py`).
- **Анти-stale-base при resume (D8, R-1):** новой 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`) по-прежнему держит гейт ⇒ анти-stale-base для нормального случая (ORCH-088) **не регрессирует**.
- **Наблюдаемость (D5):** блок `serial_gate` в `GET /queue` дополнен ключом `paused` (список приостановленных незавершённых задач репо — НЕ показываются как `active_task`) и `reason` ожидания у каждого waiting-job с приоритетом `freeze``dependency``active-task``null`; существующие ключи снапшота (`active_task`/`waiting`/`frozen`/`frozen_reason`/`frozen_at`) — байт-в-байт (BC).
- **Условность/откат (D6):** независимый под-флаг `serial_gate_pause_enabled` (env `ORCH_SERIAL_GATE_PAUSE_ENABLED`, дефолт `True`; зеркало `serial_gate_freeze_enabled`; область переиспользует `serial_gate_repos`, новый `*_repos` не вводится). Дефолт `True`**истинный no-op** до явной операторской паузы (`paused_at` всюду NULL). `False` ⇒ pause-терм опущен из SQL, эндпоинты no-op, serial-gate **байт-в-байт ORCH-088/090** (осознанный rollback-режим). Глубже — `serial_gate_enabled=false`.
- **Покрытие:** `tests/test_orch124_serial_gate_pause.py` (TC-01 обязательный регресс инцидента ORCH-116/ORCH-123 — красный до фикса, зелёный после; TC-02…TC-15: анти-регресс ORCH-088, durable/restart, resume, сохранность freeze/dependency, снапшот-reason, анти-дрейф 3 точек, offline hot-path, never-raise/fail-OPEN, kill-switch-нейтральность, структурный анти-регресс реестров/схем).
- **Доки:** обновлены `docs/architecture/README.md` (раздел serial-gate + ось «пауза без блокировки») и `docs/architecture/internals.md` (ось «пауза» ⊥ оси «терминальность»); сквозной ADR `adr-0051`.
- **Тест-гигиена (development-стадия, латентный регресс ORCH-123):** изолирован `settings.repos_dir` в фикстуре `tests/test_orch123_staging_runner_exec.py` (зеркально уже имевшейся изоляции `worktrees_dir`). `check_staging_status` при отсутствии фиче-worktree фолбэчит на `<repos_dir>/<repo>`его `origin/main`); после мержа ORCH-123 реальный `/repos/orchestrator/docs/work-items/ORCH-123/15-staging-log.md` (вердикт SUCCESS) появился на диске и делал предполагавшийся-КРАСНЫМ staging-гейт в `test_r2_held_deploy_staging_not_rolled_back` зелёным при полном прогоне `pytest tests/` (order-dependent: тест проходил в одиночку, падал в сьюте). Инвариант ORCH-123 R-2 («held `deploy-staging` не откатывается на `development`», adr-0049/ADR-001 D4) **сохранён и усилен** — изоляция лишь восстанавливает заявленную предпосылку теста «15-staging-log.md отсутствует ⇒ гейт красный». `src/**`/`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*` не тронуты (правка только теста).
- **Детерминированный test-раннер вместо LLM-тестера на `testing`** (ORCH-116, `feat`): второй реализованный срез determinization-roadmap (ORCH-118 A5, `needs-hybrid-fallback`) — на стадии `testing` для self-hosting `orchestrator` **LLM-агент `tester` заменён детерминированным кодом** (`src/test_runner.py`). PASS/FAIL-ядро агента было деривируемым (регресс `pytest` + read-only smoke → `result:`); каждый прогон жёг токены/время opus-агента (~60150k / 520 мин) и встраивал недетерминизм LLM в точку ветвления `testing → deploy-staging` / `testing → development`. **Инвариант (NFR-1):** это замена *продюсера* артефакта, **не** гейта — контракт `13-test-report.md`, гейт `check_tests_passed`/`_parse_tests_verdict`, `STAGE_TRANSITIONS`, machine-verdict `result:` (+ legacy `verdict:`/`status:`), схема БД — **байт-в-байт не тронуты**. Аддитивно, под kill-switch, never-raise, fail-closed, скоуп self-hosting, гибрид (LLM строго off-control-path). Эталон — `src/staging_runner.py` (ORCH-115). ADR: `docs/work-items/ORCH-116/06-adr/ADR-001-deterministic-test-runner.md`, сквозной `docs/architecture/adr/adr-0050-deterministic-test-runner.md`.
- **Перехват в `launch_job` до `_spawn` (D1):** `if job.agent=="tester" and test_runner.should_intercept(job)``_run_test_runner_job` (зеркало `_run_staging_runner_job`, прецедент `deploy-finalizer`/`post-deploy-monitor`/`staging-runner` `launcher.py:397/402/405`): синхронно ведёт `jobs`-строку через `mark_job`, возвращает `None` (нет `agent_runs`, нет токенов). Дискриминатор — роль `tester` **И** стадия задачи `testing` (defense-in-depth: `tester` — единственный агент входа в `testing`, коллизии стадий нет, в отличие от общей роли `deployer`) **И** `applies(repo)`; `should_intercept` never-raise → `False` → штатный `_spawn` (fail-safe к LLM-пути).
- **Leaf `src/test_runner.py` (новый, чистый never-raise):** по образцу `staging_runner`/`self_deploy`/`proc_group` (на импорте только `config`/`proc_group`; `db`/`git_worktree`/`self_deploy`/`qg.checks`/`stage_engine`/`notifications` — лениво). `applies(repo)` = kill-switch `test_runner_enabled` + скоуп `test_runner_repos` (пусто → self-hosting only) **И** резолв тест-контракта `_has_test_contract` (BR-9: репо без контракта → `False` → LLM-tester — enduro-trails 1:1 как до ORCH-116, даже если руками добавлен в CSV). Исполняет регресс `python -m pytest <test_runner_target>` **в worktree ветки** (`git_worktree.get_worktree_path`, анти checkout-гонка ORCH-112) через `proc_group.run_in_process_group` (tree-kill, таймаут `test_runner_timeout_s=900`, малформ/непозитив → дефолт + WARNING) + опц. **read-only smoke** (`/health`/`/status`/`/queue` + блок `serial_gate`, stdlib `urllib`; транзиентная недостижимость — ограниченный ретрай, не-200/нет блока — немедленный FAIL; `test_runner_smoke_enabled`). Маппит exit-код **единым** контрактом `self_deploy.map_exit_code_to_status` в токенах `result:` (`0→PASS`/иначе/None→`FAIL`, fail-closed; smoke-провал AND-ится в `FAIL`); пишет `13-test-report.md` (тот же machine-key `result:` UPPERCASE + 52c-схема, `author_agent: test-runner`/`model_used: n/a`) + best-effort push в **фичеветку**; вызывает **существующий** `advance_stage(current_stage="testing", finished_agent="tester")` — без новых рёбер/исходов (transition-lease ORCH-114 берётся внутри `advance_stage` — граница O1).

View File

@@ -621,10 +621,34 @@ ORCH-027 вводит детерминированный (без LLM) **гейт
`serial_gate_freeze_enabled`. Наблюдаемость — аддитивный блок `serial_gate` в `GET /queue`
(per-repo `active_task` / `waiting` / `frozen`). Cross-repo параллелизм сохранён (FR-3); при
выключенном флаге — нулевая регрессия (enduro не затронут).
- **Ось «пауза без блокировки» (ORCH-124 — [adr-0051](adr/adr-0051-serial-gate-pause-without-blocking.md)).**
Баг (инцидент ORCH-116/ORCH-123): serial-gate считал «активной» задачу **исключительно по машинной
стадии**, а Plane-статусы Backlog/Blocked/Needs-Input (слой B индикации, ORCH-066) **не меняют
`tasks.stage`** ⇒ приостановленный предшественник держал FIFO закрытым против срочного успешника, и у
оператора не было чистого механизма «пауза без блокировки», отдельного от cancel (терминал) и от
глобального выключения гейта. Решение — **явный per-task park-сигнал**: аддитивная колонка
`tasks.paused_at TEXT` (NULL = не на паузе; паттерн `cancelled_at`/`track`) + **новая ортогональная ось
планировщика «пауза»**, отделённая от оси «терминальность». serial-gate «активна» ⇔
`stage NOT IN ('done','cancelled') AND paused_at IS NULL` (терм `AND t2.paused_at IS NULL` во всех 3
точках под под-флагом). **Терминал `{done,cancelled}` в `serial_gate`/`task_deps`/`stages.py` —
байт-в-байт (adr-0026 не регрессирует)**: `task_deps`/`stages.py` колонку `paused_at` НЕ читают ⇒
паузнутая объявленная зависимость и `repo_freeze` **по-прежнему блокируют** (пауза их не обходит — разные
оси). Намерение — явные эндпоинты `POST /serial-gate/pause|resume?work_item=<id>` (по образцу
`unfreeze`), durable/offline/webhook-независимо (NFR-2). **Анти-stale-base (ORCH-088) не регрессирует:**
нормальная задача (`paused_at IS NULL`) держит гейт; при resume свежесть базы дают существующие механизмы
— отложенный срез (для паузнутой-в-`analysis`) и pre-merge `auto_rebase_onto_main` + merge-gate re-test
(ORCH-026/093/110) для материализованной ветки; новой rebase-машинерии нет. Наблюдаемость — ключ `paused`
+ `reason` ожидания (`active-task`/`dependency`/`freeze`) в блоке `serial_gate` `GET /queue`. Под-флаг
`serial_gate_pause_enabled` (env `ORCH_SERIAL_GATE_PAUSE_ENABLED`, дефолт `True`; зеркало
`serial_gate_freeze_enabled`); `False` ⇒ pause-терм опущен, serial-gate байт-в-байт ORCH-088/090. Дефолт
безопасен (no-op, пока ничего не паузнуто — enduro не затронут). `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/
схемы существующих таблиц — не тронуты. Детали —
`docs/work-items/ORCH-124/06-adr/ADR-001-serial-gate-pause-without-blocking.md`.
Подробнее: [adr-0017](adr/adr-0017-serial-gate.md), детально —
`docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md`,
`docs/work-items/ORCH-088/08-data-requirements.md`.
Подробнее: [adr-0017](adr/adr-0017-serial-gate.md) + [adr-0051](adr/adr-0051-serial-gate-pause-without-blocking.md)
(пауза), детально — `docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md`,
`docs/work-items/ORCH-088/08-data-requirements.md`,
`docs/work-items/ORCH-124/06-adr/ADR-001-serial-gate-pause-without-blocking.md`.
### Авто-режим по лейблам: autoApprove + autoDeploy (ORCH-089 — реализовано)
Конвейер имеет два **человеческих** гейта, тормозящих пакетный автономный прогон (эпик
@@ -1410,7 +1434,7 @@ Monitoring after Deploy → Done
## База данных (SQLite)
- `events` — входящие вебхуки (дедуп)
- `tasks` — задачи и их стадии; колонки `cancelled_at`/`cancel_requested_at` (ORCH-090) — durable-метки STOP-отмены (вторая — отложенная отмена в критичном окне merge/deploy). Терминальная стадия `cancelled` (сток, параллельно `done`); натуральные ключи отменённой строки тумбстонятся суффиксом `#cancelled-<id>` (`plane_id`/`work_item_id`/`plane_issue_id`)
- `tasks` — задачи и их стадии; колонки `cancelled_at`/`cancel_requested_at` (ORCH-090) — durable-метки STOP-отмены (вторая — отложенная отмена в критичном окне merge/deploy). Терминальная стадия `cancelled` (сток, параллельно `done`); натуральные ключи отменённой строки тумбстонятся суффиксом `#cancelled-<id>` (`plane_id`/`work_item_id`/`plane_issue_id`). Колонка `paused_at` (ORCH-124, adr-0051) — durable per-task park-сигнал serial-gate (NULL = не на паузе): **ортогональная** оси «терминальность» ось «пауза» (`paused_at IS NOT NULL`), читается **только** serial-gate (`task_deps`/`stages.py` её не читают); паузнутый предшественник не держит FIFO, но не обходит `repo_freeze`/`task_deps`
- `agent_runs` — запуски агентов (run_id, usage, cost)
- `jobs` — очередь задач (ORCH-1); статусы `queued|running|done|failed|cancelled` (ORCH-090: `cancelled` — терминальный исход STOP, нигде не реквью'ится); колонка `pid` (ORCH-065) — pid агентского процесса для liveness-детекции зомби job-reaper'ом
- `job_deps` — декларативные зависимости задач (ORCH-026, Уровень B): `(task_id, depends_on_task_id)`, аддитивная; источник истины планировщика для гейта «B ждёт A»
@@ -1429,6 +1453,8 @@ Monitoring after Deploy → Done
| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + reaper (ORCH-065) + post_deploy (ORCH-021) + task_deps (ORCH-026) + serial_gate (ORCH-088) + auto_labels (ORCH-089) + stop (ORCH-090) + lessons (ORCH-098) + transition_lease (ORCH-114) + последние jobs |
| GET | `/metrics` | ORCH-099 (FND/F1a): read-only машинное «сырьё» для sidecar F1b — конверт `schema_version`/`generated_at`/`clk_tck` + разделы `stages`/`queue`/`agents` (liveness: pid/runtime/cpu_ticks)/`cost`. never-raise по разделам; kill-switch `ORCH_METRICS_ENABLED` (дефолт `True`). Контракт — см. раздел «Сырьё-эндпоинт `/metrics`» |
| POST | `/serial-gate/unfreeze` | ORCH-088 (FR-5): ручное снятие per-repo rollback-freeze (query/body `repo=<repo>`) → `{ok, repo, cleared, frozen}`; идемпотентно. Альтернатива — `UPDATE repo_freeze SET cleared_at=datetime('now') WHERE repo=? AND cleared_at IS NULL` |
| POST | `/serial-gate/pause` | ORCH-124 (D7): поставить задачу на паузу для serial-gate (query/body `work_item=<id>`) → `{ok, work_item, task_id, paused_at}`; идемпотентно. Паузнутый предшественник не держит FIFO против срочного успешника (пауза ≠ cancel, ≠ глобальный kill-switch); НЕ обходит `repo_freeze`/`task_deps` |
| POST | `/serial-gate/resume` | ORCH-124 (D7): снять паузу (query/body `work_item=<id>`) → `{ok, work_item, task_id, paused_at: null}`; идемпотентно. Возобновлённая задача снова участвует в serial-gate; свежесть базы — существующие отложенный срез / merge-gate rebase+re-test |
| POST | `/transition-lease/release` | ORCH-114 (FR-6, **опц.**): операторский ручной реклейм застрявшего владения переходом (query/body `work_item=<id>`) → `{ok, task_id, released}`; идемпотентно (паттерн `/serial-gate/unfreeze`). При выключенном `transition_lease_enabled` → no-op |
| GET | `/lessons` | ORCH-098 (FR-4): read-only выборка журнала уроков; query-фильтры `type`/`status`/`repo`/`work_item`/`limit` → `{enabled, lessons:[…]}` (всегда `200`, чтение не мутирует). При `lessons_enabled=False` → `{enabled:false, lessons:[]}` |
| POST | `/lessons` | ORCH-098 (FR-5): ручная запись урока (JSON-тело, `lesson_type` обязателен, `source="manual"` не дедупится) → `{id}`; при выключенном флаге → `{enabled:false}` |

View File

@@ -0,0 +1,111 @@
---
work_item: ORCH-124
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-16
model_used: claude-opus-4-8
---
# ADR-0051: Ось «пауза» serial-gate — park-сигнал без блокировки FIFO
Сквозной (cross-cutting) ADR. Детальное решение задачи —
`docs/work-items/ORCH-124/06-adr/ADR-001-serial-gate-pause-without-blocking.md`.
Статус: **Proposed** · Дата: 2026-06-16 · Источник: **ORCH-124** (bug → escalate full-cycle)
## Контекст
ORCH-088 (serial-gate, adr-0017) определяет «активную задачу репо» **исключительно по машинной стадии**
`tasks.stage NOT IN ('done','cancelled')` (после ORCH-090/adr-0026 — с учётом терминала `cancelled`).
Plane-статусы Backlog/Blocked/Needs-Input — **слой B (индикация), ORCH-066** — не меняют `tasks.stage`
(слой A); у таблицы `tasks` нет колонки статуса. ⇒ приостановленная оператором задача неотличима от
активно исполняемой и держит FIFO-гейт (`t2.id < jobs.task_id`) закрытым для более поздних analyst-job
того же репо.
**Инцидент ORCH-116/ORCH-123:** ORCH-116 поставили на паузу, чтобы пропустить срочный фикс ORCH-123, но
serial-gate держал analyst-job ORCH-123 в `queued`. Единственные обходы (терминальный `cancel`, довод до
`done`, глобальное `serial_gate_enabled=false`) — грубые.
Горячий путь `serial_gate.build_claim_clause` врезан в `claim_next_job`**offline SQL** — и сетевого
чтения Plane-статуса (как делает reconciler ORCH-060) позволить не может. Нужен **DB-резолвимый** сигнал
паузы.
## Решение
### Инвариант: «пауза» — ОТДЕЛЬНАЯ ОСЬ планировщика, ортогональная «терминальности»
Вводится **per-task park-сигнал** — аддитивная нуллабельная колонка **`tasks.paused_at TEXT`**
(NULL = не на паузе) — и **новая ось планировщика «пауза»**, независимая от оси «терминальность».
| Ось | Предикат | Кто использует | Меняется ORCH-124? |
|-----|----------|----------------|--------------------|
| **Терминальность** (adr-0026) | `stage IN ('done','cancelled')` | `serial_gate` + `task_deps` + `stages.py` | **НЕТ — байт-в-байт** |
| **Пауза** (новая, ORCH-124) | `paused_at IS NOT NULL` | **только** FIFO «active» предикат `serial_gate` | да (аддитивно) |
**serial-gate «активная задача» ⇔ `stage NOT IN ('done','cancelled') AND paused_at IS NULL`.** Это
**осознанная, задокументированная** дивергенция serial-gate от чисто-терминального предиката (требование
гармонизации adr-0026): пауза выводит предшественника из FIFO-учёта serial-gate, **не делая его
терминальным**.
### Что НЕ меняется (анти-регресс adr-0026)
- **`task_deps`** (adr-0015) и **`stages.py::STAGE_TRANSITIONS`** колонку `paused_at` **не читают**
остаются чисто терминальными. Явно объявленная зависимость (`job_deps`) на **приостановленную** задачу
**по-прежнему блокирует** зависимый job. Пауза («пропустите меня в FIFO») и dependency («B нужен
результат A») — разные оси; пауза НЕ обходит dependency и НЕ обходит per-repo `repo_freeze`.
- `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict / схемы существующих таблиц — без
изменений. Пауза — не стадия и не Quality Gate, а признак планировщика очереди.
### Точки, признающие ось «пауза» (исчерпывающе)
1. `src/serial_gate.py::build_claim_clause` — терм `AND t2.paused_at IS NULL` внутри `active_clause`
(под под-флагом). **(маркер ORCH-124, рядом с ORCH-088/ORCH-090)**
2. `src/serial_gate.py::repo_has_active_task` / `_per_repo_snapshot` — тот же предикат + наблюдаемость
(ключ `paused`, `reason` ожидания).
3. `src/db.py` — колонка `tasks.paused_at` (`_ensure_column`) + хелперы `set_task_paused`/
`clear_task_paused`/`is_task_paused`.
4. `src/main.py` — операторские эндпоинты `POST /serial-gate/pause|resume` (по образцу
`POST /serial-gate/unfreeze`).
### Анти-stale-base при возобновлении (ORCH-088 не регрессирует)
Пауза «демотирует» задачу в FIFO; свежесть базы при resume обеспечивают **существующие** механизмы — новой
rebase-машинерии нет: отложенный срез ветки (ORCH-088, для паузнутой-в-`analysis`) + безусловный pre-merge
`auto_rebase_onto_main` под merge-lease (ORCH-026/093) + merge-gate re-test (ORCH-110) для уже
материализованной ветки. Нормальная задача (`paused_at IS NULL`) по-прежнему держит гейт.
### Флаги / совместимость
- Независимый под-флаг `serial_gate_pause_enabled` (env `ORCH_SERIAL_GATE_PAUSE_ENABLED`, дефолт `True`) —
зеркало `serial_gate_freeze_enabled`. `False` ⇒ pause-терм опущен из SQL, эндпоинты no-op ⇒ serial-gate
байт-в-байт как ORCH-088/090. Область — переиспользует `serial_gate_repos` (новый `*_repos` не вводится).
- Дефолт `True` безопасен: пока ни одна задача не на паузе, `paused_at` везде `NULL` ⇒ истинный no-op
(enduro не затронут).
- never-raise: pause-терм в `build_claim_clause` сохраняет **fail-OPEN**; freeze — **fail-CLOSED**.
- Миграция — только аддитивная/идемпотентная (`_ensure_column`); общая прод-БД безопасна (NFR-3).
## Последствия
- **+** Чистая операторская «пауза без блокировки», отличная от cancel (терминал) и от kill-switch;
durable, offline, webhook-независимая; закрывает инцидент ORCH-116/ORCH-123.
- **+** Единый, явно описанный двухосевой предикат планировщика (терминальность ⊥ пауза) — устранён риск
будущего рассинхрона.
- **** Появилась вторая ось «активности» serial-gate — будущие подсистемы планировщика обязаны помнить:
serial-gate «активна» = `не терминальна И не на паузе`, но **терминал** (`task_deps`/`stages.py`) ось
«пауза» НЕ включает. Митигейшн: этот ADR + маркер `ORCH-124` в изменённых местах + тесты.
- **Откат:** `ORCH_SERIAL_GATE_PAUSE_ENABLED=false` (serial-gate 1:1 как ORCH-088/090; колонка `paused_at`
инертна).
## Эволюция маркеров
Горячий SQL serial-gate несёт теперь 3 маркера (`ORCH-088` FIFO-гейт, `ORCH-090` терминал `cancelled`,
`ORCH-124` ось паузы) — правка любого из них сверяется с этим сводным ADR (анти-археология: 3+ маркеров →
одна ссылка сюда, `docs/_standards/TRACEABILITY.md`).
## Ссылки
- Детальный ADR: `docs/work-items/ORCH-124/06-adr/ADR-001-serial-gate-pause-without-blocking.md`
- Данные: `docs/work-items/ORCH-124/08-data-requirements.md`
- Связанные: adr-0017 (serial-gate ORCH-088), adr-0026 (терминал `{done,cancelled}` ORCH-090),
adr-0015 (task-deps), adr-0027 (merge-актор rebase/retry ORCH-093), adr-0042 (merge-gate re-test ORCH-110)
</content>

View File

@@ -70,6 +70,14 @@ STAGE_TRANSITIONS = {
рёбер не меняются), а терминал STOP-отмены. Системный предикат «задача завершена» —
`stage ∈ {done, cancelled}` (синхронно в `reconciler`/`serial_gate`/`task_deps`; adr-0026).
**Ось «пауза» ⊥ оси «терминальность» (ORCH-124, adr-0051):** serial-gate вводит **отдельную** ось
паузы `tasks.paused_at IS NOT NULL` (durable per-task park-сигнал) — **ортогональную** терминалу. Для
serial-gate «активная задача» ⇔ `stage NOT IN ('done','cancelled') AND paused_at IS NULL` (паузнутый
предшественник не держит FIFO). **Терминал `{done,cancelled}` НЕ расширяется паузой:** `task_deps` и
`stages.py` колонку `paused_at` НЕ читают (паузнутая объявленная зависимость по-прежнему блокирует
зависимый job; пауза не обходит `repo_freeze`). Пауза — признак планировщика очереди, не стадия и не
терминальное состояние.
### 3. Quality Gates (`src/qg/checks.py`)
| Check | Метод проверки |

View File

@@ -0,0 +1,7 @@
# Business Request: BUG: serial gate treats Backlog/Blocked paused tasks as active and blocks urgent successors
Work Item ID: ORCH-124
## Description
TBD

View File

@@ -0,0 +1,185 @@
---
work_item: ORCH-124
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-16
model_used: claude-opus-4-8
escalate: full-cycle
---
# 01 — BRD / Bug-report: ORCH-124 — serial gate treats Backlog/Blocked/Needs-Input paused tasks as active and blocks urgent successors
Work Item: **ORCH-124** · Repo: **orchestrator** · Стадия: analysis · Трек: **Bug → эскалация в full-cycle**
> ⚠️ **`escalate: full-cycle` (ADR-001 D5 ORCH-019).** Метка задачи — `Bug`, но по сути это
> **архитектурный** дефект: требуется **определить семантику wait/terminal состояний serial-gate**
> и выбрать механизм «пауза без блокировки» (release-on-status / явный per-task hold-флаг /
> переиспользование `task_deps`). Любой вариант пересекается с **корневым инвариантом ORCH-088
> (анти-stale-base)** и с гармонизированным терминальным предикатом ORCH-090 (`adr-0026`,
> `serial_gate` + `task_deps` + `stages.py`). Это не «однострочная» правка — нужен ADR с явным
> разрешением конфликта свойств (см. §8 и `10-tech-risks.md` архитектора). Поэтому выпускается
> **полный** analysis-пакет (а не облегчённый bug-пакет). Оператор снимает багфикс-трек:
> `POST /bug-fast-track/escalate?work_item=ORCH-124` → задача пойдёт через стадию `architecture`
> (architect выпустит ADR для семантики паузы serial-gate).
---
## 1. Бизнес-контекст и проблема
### Симптом (наблюдаемое — установленный факт инцидента)
Во время инцидента **ORCH-116/ORCH-123**: задачу **ORCH-116** намеренно поставили на паузу
(перевели в Plane-статус Blocked/Backlog), чтобы вперёд пропустить срочный фикс **ORCH-123**.
Однако `serial_gate` **по-прежнему считал ORCH-116 активной задачей** (`active_task`) и держал
analyst-job ORCH-123 в очереди (`queued`) — срочный фикс не мог стартовать, пока ORCH-116
формально не `done`/`cancelled`.
### Причина симптома (установленный факт — верифицировано по коду)
`serial_gate` определяет «активную задачу репо» **исключительно по машинной стадии**
`tasks.stage NOT IN ('done','cancelled')` — в трёх местах `src/serial_gate.py`:
- `build_claim_clause()` — горячий SQL-фрагмент в `db.claim_next_job`:
`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-зеркало для наблюдаемости;
- `_per_repo_snapshot()` — выбор `active_task` для блока `serial_gate` в `GET /queue`.
При этом **Plane-статусы Backlog / Blocked / Needs Input — это слой B (индикация), ORCH-066**, и они
**не меняют `tasks.stage` (слой A)**. Сеттеры `set_issue_blocked` / `set_issue_needs_input`
(`src/plane_sync.py`) делают только `PATCH` Plane-статуса; машинная стадия задачи остаётся прежней
(`analysis` / `development` / `deploy-staging` …). Подтверждение из кода: у таблицы `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 того же репо.
### Почему это важно (бизнес-боль)
- **Срочный фикс не запускается**, пока более ранняя задача поставлена на паузу. Единственные
существующие способы «разблокировать» — терминально `cancel`/довести до `done`, либо целиком
выключить serial-gate (`ORCH_SERIAL_GATE_ENABLED=false`) для всех репо. Все три — грубые.
- У оператора **нет чистого механизма «пауза без блокировки»** с явным намерением — отдельного от
отмены (терминал) и от глобального выключения гейта.
- На пакетном автономном прогоне (эпик ORCH-088) это превращает любую «отложенную» задачу в стоп-кран
очереди репо.
### Прецедент в коде (контекст для архитектора, не решение)
Reconciler уже **умеет** уважать wait-состояния: ORCH-060 Guard 2 (`reconciler._is_blocked_or_needs_input`,
Variant A) **сетевым** запросом Plane-статуса пропускает Blocked/Needs-Input (и активные
ORCH-066-ожидания) и не «оживляет» их. Но reconciler — фоновый тик и **может** позволить себе сетевой
вызов; `serial_gate.build_claim_clause` врезан в `claim_next_job` (**offline hot-path**) и сетевого
вызова позволить **не может** (NFR-2 ниже). Это центральное расхождение, которое и порождает баг:
сигнал паузы есть в Plane, но не доступен горячему SQL гейта.
## 2. Объём (scope)
### В объёме
- Определить **семантику wait/terminal** для serial-gate: какие состояния задачи-предшественника
НЕ должны держать FIFO-гейт закрытым для более поздней задачи.
- Дать оператору **явный, durable, DB-резолвимый** механизм «пауза без блокировки» (или формально
переиспользовать существующий: freeze / task_deps), с чётким намерением, отличным от cancel.
- Поправить определение «активной задачи» во **всех трёх** точках `serial_gate.py`, чтобы
приостановленная задача не считалась `active_task`.
- Корректная **причина ожидания** в блоке `serial_gate` снапшота `GET /queue`
(active task / paused-predecessor / freeze / dependency).
- Тесты: предшественник Blocked/Backlog/Needs-Input + срочный успешник; регресс-тест инцидента.
### Вне объёма
- Изменения `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключей — **не трогаем**
(маршрутизация очереди — свойство планировщика, не Quality Gate).
- Введение нового **машинного** статуса в `STAGE_TRANSITIONS` (это не новая стадия конвейера).
- Изменение поведения reconciler ORCH-060 (его networked-skip уже корректен; гармонизация — на
усмотрение архитектора, но переписывать его не требуется).
- Автоматическое управление паузой по данным вне явного намерения оператора (никакого эвристического
«само-распаузивания»).
- Конкретный **выбор механизма** (release-on-status vs per-task hold-флаг vs task_deps) — это решение
**архитектора** (ADR), а не аналитика.
## 3. Заинтересованные стороны
- **Оператор/владелец конвейера (Слава)** — заказчик: нуждается в чистой паузе, чтобы пропускать
срочные фиксы без отмены и без выключения гейта.
- **Self-hosting orchestrator** — затрагивается напрямую (serial-gate активен для всех репо).
- **enduro-trails** — затрагивается косвенно (общая БД/очередь); регрессия недопустима при
выключенном/нейтральном поведении.
- **Архитектор** — принимает решение о механизме и семантике (ADR), разрешает конфликт §8.
- Принимает результат — reviewer + tester по критериям `03-acceptance-criteria.md`.
## 4. Бизнес-требования (BR)
- **BR-1** — Перевод задачи-предшественника в состояние паузы/ожидания (Backlog / Blocked /
Needs Input) **больше не должен случайно блокировать** более позднюю срочную задачу того же репо в
serial-gate. Проверяемо: analyst-job успешника становится claimable.
- **BR-2** — У оператора есть **чистый механизм «пауза без блокировки»** с явным намерением,
**отличный** от `cancel` (терминал) и от глобального выключения гейта. Намерение — durable
(переживает рестарт процесса/контейнера).
- **BR-3** — Пауза снимает гейт **только по явному намерению**. **Нормально исполняемая** задача
(реально идёт работа) **по-прежнему держит** гейт — анти-stale-base гарантия ORCH-088 не
регрессирует (см. §8 — конфликт свойств, разрешает архитектор).
- **BR-4** — Снапшот `serial_gate` в `GET /queue` показывает **корректную причину** ожидания
успешника: активная задача / приостановленный предшественник / freeze / dependency.
- **BR-5** — При **возобновлении** (распаузе) задачи serial-ordering корректно восстанавливается:
возобновлённая задача снова участвует в гейте (либо держит его, либо явно ре-входит в FIFO с
обязательством rebase) — нет «вечного обхода» и нет потерянного намерения.
- **BR-6** — Существующие гарантии serial-gate сохранены: FIFO по более ранним незавершённым
задачам, durable per-repo `freeze` (`repo_freeze`), cross-repo параллелизм, явные `task_deps`
по-прежнему блокируют, где должны.
## 5. Нефункциональные требования (NFR)
- **NFR-1 (never-raise / fail-safe)** — Контракт leaf `serial_gate` сохранён: каждая публичная
функция деградирует консервативно. Сохранить два направления отказа ORCH-088: hot-claim build →
**fail-OPEN** (`""`-фрагмент, не заклинить очередь всех проектов, AC-8 ORCH-088); freeze-решение →
**fail-CLOSED** (прод-безопасность, AC-9 ORCH-088). Новая логика паузы не должна инвертировать эти
направления.
- **NFR-2 (чистота hot-path)** — Гейт-в-claim остаётся **offline SQL-предикатом**; **никаких сетевых
вызовов** (в т.ч. Plane API) в `claim_next_job`. Сигнал «пауза» обязан быть **DB-резолвимым**
(durable колонка/таблица), а не считываться из Plane на горячем пути (в отличие от reconciler).
- **NFR-3 (обратная совместимость / kill-switch / область)** — Изменение аддитивно и обратимо;
выключатель (существующий `serial_gate_enabled` либо новый под-флаг) → байт-в-байт прежнее поведение
до ORCH-124. `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи / **схемы
существующих таблиц** — без изменений (новая колонка/таблица — только аддитивно, паттерн
`_ensure_column` / `CREATE TABLE IF NOT EXISTS`). enduro не затронут при нейтральном поведении.
- **NFR-4 (гармонизация предиката)** — Любой новый предикат «активна/терминальна/пауза» должен
оставаться согласованным с гармонизированным терминальным множеством `{done,cancelled}` в
`serial_gate` + `task_deps` + `stages.py` (ORCH-090 / adr-0026), либо архитектор явно описывает,
почему serial-gate расходится (паузой), не ломая `task_deps`.
- **NFR-5 (self-hosting безопасность)** — Никакого рестарта/падения прод-контейнера, мутации `main`,
force-push; только чтение/запись своих таблиц и принятие решения о claim. Hot-path не должен
замедляться сетью или тяжёлым запросом.
## 6. Допущения и ограничения
- У таблицы `tasks` **сегодня нет колонки статуса**; Plane-статус (Backlog/Blocked/Needs Input) —
слой B индикации, в БД не отражён. Значит «пауза» для горячего пути требует **нового durable
DB-сигнала** (колонка `tasks` или отдельная таблица), либо переиспользования уже DB-резолвимого
механизма (`repo_freeze` / `task_deps`).
- `repo_freeze` существует, но **freeze'ит весь репо** (блокирует всех успешников) — это
противоположность «пропустить срочного успешника», поэтому как есть не годится для BR-1 (но годится
как явный блок для BR-6).
- `task_deps` (`job_deps`) — явные декларации зависимостей, уже DB-резолвимы и консультируются в
`claim_next_job` (`NOT EXISTS`); кандидат на «explicit intent», на усмотрение архитектора.
- Reconciler ORCH-060 различает Blocked/Needs-Input **сетевым** запросом Plane — прецедент семантики,
но **не переиспользуем** на hot-path (NFR-2).
- Серый кейс: Needs Input во время `analysis` — нормальное короткое ожидание ответа; решение, считать
ли его «паузой для гейта», за архитектором (важно не превратить штатное короткое ожидание в обход
анти-stale-base).
## 7. Критерии успеха
Кратко (детальные PASS/FAIL — `03-acceptance-criteria.md`):
- Приостановленный предшественник (Backlog/Blocked/Needs-Input по явному намерению) не блокирует
срочного успешника; нормально идущая задача — блокирует; freeze/dependency блокируют, где должны;
`GET /queue` показывает корректную причину; всё под kill-switch; машинные инварианты байт-в-байт;
регресс-тест инцидента красный до фикса и зелёный после.
## 8. Риски
Кратко (детально — `10-tech-risks.md`, заполняет архитектор):
- **R-1 (ключевой конфликт свойств) — пауза vs анти-stale-base (ORCH-088).** Если «пауза» освобождает
гейт, успешник срежет ветку от `main`, ещё **не** содержащего код предшественника. Когда
приостановленный предшественник позже возобновится и смержится — его база/ветка могут стать stale.
ORCH-088 был построен ровно чтобы это предотвратить. Архитектор обязан разрешить конфликт явно
(напр.: пауза «демотирует» задачу в FIFO и обязывает rebase при возобновлении; либо явный per-task
«yield» с принятием rebase). **Аналитик фиксирует конфликт, не выбирает решение.**
- **R-2** — Случайное/неявное освобождение гейта (баг в детекте намерения) ослабит сериализацию для
всех — требуется строго **явное** намерение оператора.
- **R-3** — Рассинхрон «Plane-статус ↔ DB-сигнал паузы»: если механизм опирается на webhook о смене
статуса, потерянный webhook оставит задачу «активной» в БД (или наоборот). Нужен durable,
идемпотентный, восстановимый сигнал.
- **R-4** — Регрессия гармонизированного предиката `{done,cancelled}` в `task_deps`/`stages.py`, если
serial-gate изменит понимание «активности» неаккуратно.
- **R-5** — fail-direction: ошибка в новой ветке не должна инвертировать fail-OPEN (claim) /
fail-CLOSED (freeze) контракты ORCH-088.

View File

@@ -0,0 +1,133 @@
---
work_item: ORCH-124
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-16
model_used: claude-opus-4-8
escalate: full-cycle
---
# 02 — ТЗ (TRZ): ORCH-124 — wait/terminal-семантика serial-gate (пауза без блокировки)
Work Item: **ORCH-124** · Repo: **orchestrator** · Стадия: analysis
> ТЗ описывает **требования к изменениям**, выведенные из BRD и фактического кода. **Выбор
> механизма** «паузы без блокировки» (release-on-status / per-task hold-флаг / task_deps) и его
> архитектурное обоснование — задача **архитектора** (`06-adr`, эскалация в full-cycle). Ниже —
> что должно стать истинным и какие модули это затронет, без предписания «как именно».
## 1. Сводка изменения
Сейчас serial-gate (`src/serial_gate.py`) считает «активной» любую задачу репо со стадией вне
`{done,cancelled}` — в трёх точках (`build_claim_clause` / `repo_has_active_task` /
`_per_repo_snapshot`). Поскольку Plane-статусы Backlog/Blocked/Needs-Input (слой B индикации) **не
меняют `tasks.stage`** (слой A), приостановленная задача неотличима от активной и держит FIFO-гейт
закрытым для более поздних analyst-job. Требуется ввести **явный, durable, DB-резолвимый** признак
«пауза/park» и научить определение «активной задачи» его учитывать, **сохранив** анти-stale-base
ORCH-088 при возобновлении (R-1). Машинные гейты не трогаются — это правка **планировщика очереди**.
## 2. Задействованные модули / пути
| Путь | Действие |
|------|----------|
| `src/serial_gate.py` | изменить — определение «активной задачи» во всех 3 точках (`build_claim_clause`, `repo_has_active_task`, `_per_repo_snapshot`); причина ожидания в снапшоте |
| `src/db.py` | изменить (вероятно) — `claim_next_job` (учёт нового предиката в горячем SQL) + (если выбран DB-сигнал) аддитивная колонка/таблица `_ensure_column`/`CREATE TABLE IF NOT EXISTS` + read-only/мутатор-хелперы |
| `src/config.py` | изменить — под-флаг(и) семантики паузы (kill-switch), область репо (по образцу `serial_gate_*`) |
| `src/main.py` | изменить (вероятно) — операторский эндпоинт pause/resume **или** расширение блока `serial_gate` в `GET /queue` причиной ожидания |
| `src/webhooks/plane.py` и/или `src/plane_sync.py` | изменить (если механизм = release-on-status) — захват намерения паузы из смены Plane-статуса в durable DB-сигнал (не на hot-path) |
| `tests/test_serial_gate*.py` (новый `tests/test_orch124_serial_gate_pause.py`) | создать/дополнить — кейсы паузы + регресс инцидента |
| `docs/architecture/README.md`, `CHANGELOG.md`, `docs/work-items/ORCH-124/06-adr/` | обновить — раздел serial-gate + ADR (архитектор) |
> Точный набор модулей зависит от выбранного механизма (ADR). Минимально-необходимый набор —
> `serial_gate.py` (3 точки) + `db.py` (hot-path) + `config.py` (флаг) + тесты; остальное — по решению
> архитектора.
## 3. Функциональные требования
### FR-1 — Признак паузы исключает задачу из «активных» в горячем SQL (BR-1, NFR-2)
`build_claim_clause()`: подзапрос `EXISTS (... t2.stage NOT IN ('done','cancelled'))` должен
**дополнительно** исключать приостановленные задачи-предшественники. Предикат — **чисто SQL по
локальной БД** (никакой сети). Форма исключения — функция выбранного DB-сигнала (доп. колонка
`tasks.<paused>` / отдельная таблица hold / `task_deps`): архитектор фиксирует точную SQL-форму в ADR.
Инвариант: job'ы уже активной задачи (`agent != 'analyst'`) проходят как раньше; самоблокировки
собственной строки (R-7 ORCH-088) нет.
### FR-2 — Зеркало и снапшот согласованы с FR-1 (BR-1/BR-4)
`repo_has_active_task()` и `_per_repo_snapshot()` используют **тот же** предикат «активна», что и
`build_claim_clause` — приостановленный предшественник не попадает в `active_task`. Все три точки
правятся согласованно (анти-дрейф: они должны давать один ответ на один вход).
### FR-3 — Явное durable намерение паузы (BR-2, BR-5, NFR-2)
Должен существовать **явный** оператор-инициируемый сигнал «park/pause» задачи, **durable**
(переживает рестарт) и **DB-резолвимый**. Распауза (resume) — обратная операция, восстанавливающая
участие задачи в serial-gate (BR-5). Сигнал **отличен** от `cancel`/`done` (не терминальный) и от
глобального kill-switch. Конкретная форма (новый эндпоинт `POST /serial-gate/pause|resume`, или
маппинг Plane-статуса в DB-сигнал через webhook, или декларация `task_deps`) — **решение архитектора**.
### FR-4 — Анти-stale-base при возобновлении (BR-3, R-1 — критично)
Решение обязано **не регрессировать** ORCH-088: нормально исполняемая задача держит гейт; а
возобновлённая ранее приостановленная задача не должна приводить к stale-базе у успешника, который
прошёл вперёд. Механизм разрешения (демотирование в FIFO + обязательный rebase при resume / явный
yield с принятием rebase / иное) фиксируется архитектором в ADR. ТЗ требует лишь: после распаузы
ни одна из задач не остаётся на устаревшей базе незаметно.
### FR-5 — Корректная причина ожидания (BR-4)
Блок `serial_gate` в `GET /queue` для ожидающего успешника различает причины: `active-task`
(идёт работа) / `paused-predecessor` (предшественник на паузе — не должно случаться после фикса, но
наблюдаемо) / `freeze` / `dependency`. Аддитивно к существующим ключам снапшота (`active_task` /
`waiting` / `frozen` / `frozen_reason` / `frozen_at`).
### FR-6 — Явные блоки сохранены (BR-6)
`repo_freeze` (durable per-repo freeze) и `task_deps` (`job_deps`) продолжают блокировать claim ровно
как сейчас. «Пауза» НЕ должна обходить freeze/dependency — это разные оси (freeze = весь репо;
dependency = конкретная пара; пауза = «пропустите меня»).
### FR-7 — Условность и нейтральность (NFR-3)
Поведение под выключателем (существующий `serial_gate_enabled` либо новый под-флаг паузы) → байт-в-байт
до ORCH-124. Область репо — по образцу `serial_gate_repos` (CSV; пусто → текущая область serial-gate,
т.е. все репо). enduro не затронут при нейтральном поведении.
## 4. Изменения API
Вероятно (на усмотрение архитектора, ADR):
- **Новые операторские эндпоинты** `POST /serial-gate/pause?work_item=<id>` и
`POST /serial-gate/resume?work_item=<id>` (по образцу существующего `POST /serial-gate/unfreeze`),
если механизм = явный per-task hold. Возвращают новое состояние (paused/active) задачи.
- **Расширение** `GET /queue` → блок `serial_gate` дополняется причиной ожидания (FR-5) и (если есть)
списком приостановленных задач репо. Существующие ключи не меняются (BC).
Если механизм = release-on-status, новых ручных эндпоинтов может не быть (намерение — смена Plane-статуса,
захватываемая webhook'ом в durable DB-сигнал); тогда раздел сводится к расширению `GET /queue`.
## 5. Изменения схемы БД
Вероятно **аддитивно** (на усмотрение архитектора):
- Колонка `tasks.paused_at TEXT` (NULL = не на паузе), идемпотентно через `_ensure_column` — паттерн
существующих `tasks.cancelled_at` / `tasks.cancel_requested_at` / `tasks.track`; **или**
- Отдельная таблица hold (по образцу `repo_freeze`: `work_item_id`/`task_id`, `paused_at`,
`cleared_at IS NULL` = активна), `CREATE TABLE IF NOT EXISTS`.
Схемы **существующих** таблиц (`tasks`/`jobs`/`job_deps`/`repo_freeze`) — без деструктивных изменений.
Если механизм = task_deps, новой схемы может не понадобиться вовсе. Финальное решение — ADR +
`08-data-requirements.md` (архитектор).
## 6. Требования к новым/изменённым QG checks
**Нет.** `STAGE_TRANSITIONS` / состав `QG_CHECKS` / семантика и имена `check_*` / machine-verdict ключи
(`verdict:`/`result:`/`deploy_status:`/`staging_status:`/`security_status:`) — **байт-в-байт не
трогаются**. ORCH-124 — правка планировщика очереди (claim) и наблюдаемости, **не** Quality Gate и
**не** новая стадия конвейера.
## 7. Совместимость / регресс
- **Обратная совместимость:** аддитивно; выключатель → поведение до ORCH-124 байт-в-байт (NFR-3,
FR-7). Существующие тесты serial-gate (`tests/test_serial_gate*.py`) остаются зелёными.
- **Kill-switch / область:** переиспользовать `serial_gate_enabled` (kill-switch) + при необходимости
ввести под-флаг семантики паузы (независимый тумблер, по образцу `serial_gate_freeze_enabled`) и
область `*_repos` (CSV).
- **Обратимость:** выставить под-флаг паузы в `False` → serial-gate работает как ORCH-088/090
(приостановленные снова считаются активными — т.е. возврат к текущему багу, но это осознанный
rollback-режим, не дефолт).
- **never-raise / fail-direction (NFR-1):** сохранить fail-OPEN на hot-claim build и fail-CLOSED на
freeze; новая ветка паузы не инвертирует эти направления.
- **Гармонизация предиката (NFR-4):** не сломать `task_deps`/`stages.py` терминальное множество
`{done,cancelled}` (ORCH-090/adr-0026); расхождение serial-gate (паузой) — только осознанно и
задокументированно в ADR.
- **Артефакты pipeline:** обновляются `docs/architecture/README.md` (раздел serial-gate ORCH-088 +
семантика паузы), `CHANGELOG.md`, новый `docs/work-items/ORCH-124/06-adr/ADR-001-*.md` (архитектор),
при необходимости `08-data-requirements.md` (архитектор). Reviewer проверяет обновление доки (правило
агентов §6).

View File

@@ -0,0 +1,136 @@
---
work_item: ORCH-124
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-16
model_used: claude-opus-4-8
escalate: full-cycle
---
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-124 — wait/terminal-семантика serial-gate
Work Item: **ORCH-124** · Repo: **orchestrator** · Стадия: analysis
Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** (что считается
провалом). Reviewer/tester проверяет их буквально по файлам репозитория и тестам.
---
## AC-1 — Приостановленный предшественник не блокирует срочного успешника (регресс инцидента)
**Условие:** репо имеет более раннюю задачу A (`A.id < B.id`) в состоянии паузы (Blocked/Backlog/
Needs-Input по явному намерению оператора) и более позднюю срочную задачу B с queued analyst-job.
- **PASS:** `build_claim_clause()` НЕ блокирует analyst-job задачи B; `claim_next_job()` выбирает его;
B входит в `analysis`. Тест воспроизводит инцидент ORCH-116/ORCH-123 (красный до фикса, зелёный
после).
- **FAIL:** analyst-job B остаётся `queued`, потому что приостановленная A всё ещё считается активной.
---
## AC-2 — Нормально исполняемая задача по-прежнему держит гейт (анти-stale-base, без регресса ORCH-088)
**Условие:** репо имеет более раннюю задачу A, **реально исполняемую** (не на паузе, стадия вне
`{done,cancelled}`) и более позднюю задачу B с queued analyst-job.
- **PASS:** analyst-job B **остаётся заблокированным** (FIFO ORCH-088 цел); B стартует только когда A
становится `done`/`cancelled` (или явно поставлена на паузу оператором).
- **FAIL:** B стартует поверх ещё не завершённой активной A → возврат stale-base дефекта, который
закрывал ORCH-088.
---
## AC-3 — Пауза снимает гейт только по явному durable намерению
**Условие:** способ перевода задачи в «паузу» для serial-gate.
- **PASS:** освобождение гейта происходит **только** при явном операторском сигнале (эндпоинт
pause / выбранный механизм ADR), сигнал **durable** (переживает рестарт процесса/контейнера) и
**DB-резолвимый**. Никакого эвристического само-распаузивания.
- **FAIL:** гейт снимается без явного намерения (например, по умолчанию для любой не-`done` задачи) ИЛИ
намерение теряется после рестарта.
---
## AC-4 — Анти-stale-base при возобновлении (R-1 разрешён архитектором)
**Условие:** приостановленная A возобновлена после того, как успешник B уже прошёл вперёд.
- **PASS:** ни A, ни B не остаются незаметно на устаревшей базе — выбранный механизм (демотирование A
в FIFO + обязательный rebase / явный yield с rebase / иное по ADR) гарантирует, что возобновлённая
задача строится/мержится на актуальном `origin/main`. Поведение зафиксировано тестом по контракту ADR.
- **FAIL:** возобновлённая A или прошедший B приводит к stale-ветке/затиранию кода без сигнала.
---
## AC-5 — Явные блоки (freeze + dependency) сохранены
**Условие:** активный `repo_freeze` для репо ИЛИ объявленная незавершённая зависимость в `job_deps`.
- **PASS:** claim успешника по-прежнему **заблокирован** freeze'ем (до ручного
`POST /serial-gate/unfreeze`) и/или незавершённой зависимостью (`task_deps`) — пауза их не обходит.
- **FAIL:** «пауза» позволяет обойти freeze или объявленную зависимость.
---
## AC-6 — Корректная причина ожидания в `GET /queue`
**Условие:** блок `serial_gate` в снапшоте `GET /queue`.
- **PASS:** для ожидающего успешника видна корректная причина ожидания (`active-task` /
`paused-predecessor` / `freeze` / `dependency`); приостановленный предшественник НЕ показывается как
`active_task`. Существующие ключи снапшота не сломаны (BC).
- **FAIL:** снапшот показывает приостановленную задачу как `active_task` ИЛИ причина ожидания неверна/
отсутствует.
---
## AC-7 — Hot-path остаётся offline (без сети)
**Условие:** путь `db.claim_next_job``serial_gate.build_claim_clause`.
- **PASS:** определение «активна/пауза» резолвится **только** локальной БД (SQL); ни одного сетевого
вызова (Plane API и т.п.) на горячем пути claim. Проверяемо тестом (claim работает без сети / без
Plane).
- **FAIL:** claim делает сетевой вызов для определения паузы.
---
## AC-8 — Машинные инварианты байт-в-байт; kill-switch off → прежнее поведение
**Условие:** реестры и выключатель.
- **PASS:** `STAGE_TRANSITIONS` / состав `QG_CHECKS` / имена и семантика `check_*` / machine-verdict
ключи / схемы существующих таблиц — без изменений (структурный тест). При выключенном под-флаге паузы
serial-gate ведёт себя байт-в-байт как ORCH-088/090.
- **FAIL:** изменён любой machine-verdict ключ / состав `QG_CHECKS` / `STAGE_TRANSITIONS`, либо
выключенный флаг меняет поведение.
---
## AC-9 — never-raise и сохранённые fail-directions
**Условие:** ошибки внутри новой логики паузы.
- **PASS:** все публичные функции `serial_gate` never-raise; hot-claim build по-прежнему **fail-OPEN**
(`""`-фрагмент при ошибке), freeze-решение по-прежнему **fail-CLOSED**; новая ветка паузы не
инвертирует эти направления. Проверяемо тестом (инъекция ошибки → claim не падает, очередь не
заклинивает).
- **FAIL:** ошибка в ветке паузы роняет claim/worker ИЛИ инвертирует fail-direction.
---
## AC-10 — Возобновление восстанавливает участие в гейте (BR-5)
**Условие:** распауза ранее приостановленной задачи.
- **PASS:** после resume задача снова участвует в serial-gate согласно выбранной семантике (держит
гейт как активная либо ре-входит в FIFO); нет «вечного обхода».
- **FAIL:** возобновлённая задача навсегда остаётся вне гейта / её намерение паузы «залипает».
---
## Сводная матрица AC ↔ FR/BR
| AC | Покрывает |
|----|-----------|
| AC-1 | BR-1 / FR-1, FR-2 |
| AC-2 | BR-3 / FR-4 |
| AC-3 | BR-2 / FR-3 |
| AC-4 | BR-3, BR-5 / FR-4 |
| AC-5 | BR-6 / FR-6 |
| AC-6 | BR-4 / FR-5 |
| AC-7 | NFR-2 / FR-1 |
| AC-8 | NFR-3, NFR-4 / FR-7 |
| AC-9 | NFR-1 / FR-7 |
| AC-10 | BR-5 / FR-3 |

View File

@@ -0,0 +1,112 @@
work_item: ORCH-124
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-16
model_used: claude-opus-4-8
title: "Serial-gate wait/pause semantics — paused predecessor must not block urgent successor"
framework: pytest
scope: >
Покрывает определение "активной задачи" в serial-gate (build_claim_clause /
repo_has_active_task / _per_repo_snapshot), durable явный сигнал паузы и его учёт в
горячем SQL claim, сохранность анти-stale-base ORCH-088, явных блоков (freeze/task_deps),
наблюдаемости GET /queue, never-raise/fail-directions и kill-switch. Вне покрытия:
изменения STAGE_TRANSITIONS/QG_CHECKS/check_* (их НЕТ), сетевые вызовы Plane API на hot-path
(запрещены — проверяется их ОТСУТСТВИЕ), реальный прод-деплой/рестарт.
notes: >
TC-01 — ОБЯЗАТЕЛЬНЫЙ регресс-тест инцидента ORCH-116/ORCH-123: красный до фикса, зелёный
после. Точные имена флагов/колонок/эндпоинтов и SQL-форма предиката зависят от выбранного
механизма (ADR архитектора) — тест-план фиксирует ПОВЕДЕНИЕ, не реализацию. Тесты
работают на временной SQLite-БД без сети/Plane/прода (паттерн tests/test_serial_gate*.py).
Полный регресс tests/ должен оставаться зелёным (особенно test_serial_gate*.py).
tests:
- id: TC-01
type: integration
description: "РЕГРЕСС (обязательный): репо с более ранней приостановленной задачей A + более поздняя срочная B → claim_next_job выбирает analyst-job B (гейт открыт). Красный до фикса, зелёный после. Воспроизводит ORCH-116/ORCH-123."
module: tests/test_orch124_serial_gate_pause.py
expected: PASS
- id: TC-02
type: unit
description: "Предшественник в Backlog (по явному намерению) не считается активным: build_claim_clause не блокирует analyst-job успешника."
module: tests/test_orch124_serial_gate_pause.py
expected: PASS
- id: TC-03
type: unit
description: "Предшественник в Needs-Input (по явному намерению) не блокирует успешника — параллельно AC-1 для иного wait-состояния."
module: tests/test_orch124_serial_gate_pause.py
expected: PASS
- id: TC-04
type: unit
description: "АНТИ-РЕГРЕСС ORCH-088: нормально исполняемый предшественник (не на паузе, стадия вне done/cancelled) ПО-ПРЕЖНЕМУ блокирует analyst-job успешника (FIFO цел)."
module: tests/test_orch124_serial_gate_pause.py
expected: PASS
- id: TC-05
type: unit
description: "Пауза требует явного durable намерения: задача без операторского сигнала паузы остаётся активной (гейт держится); сигнал паузы DB-резолвим."
module: tests/test_orch124_serial_gate_pause.py
expected: PASS
- id: TC-06
type: unit
description: "Durable: сигнал паузы переживает пересоздание соединения/рестарт (читается из БД, не из памяти процесса)."
module: tests/test_orch124_serial_gate_pause.py
expected: PASS
- id: TC-07
type: unit
description: "Возобновление (resume) восстанавливает участие задачи в serial-gate (держит гейт / ре-входит в FIFO согласно ADR); нет вечного обхода."
module: tests/test_orch124_serial_gate_pause.py
expected: PASS
- id: TC-08
type: unit
description: "Явные блоки сохранены: активный repo_freeze продолжает блокировать claim успешника; пауза freeze не обходит."
module: tests/test_orch124_serial_gate_pause.py
expected: PASS
- id: TC-09
type: unit
description: "Явные блоки сохранены: незавершённая объявленная зависимость (job_deps/task_deps) продолжает блокировать claim; пауза dependency не обходит."
module: tests/test_orch124_serial_gate_pause.py
expected: PASS
- id: TC-10
type: unit
description: "GET /queue snapshot: приостановленный предшественник НЕ показывается как active_task; причина ожидания успешника корректна (active-task/paused-predecessor/freeze/dependency); существующие ключи snapshot сохранены."
module: tests/test_orch124_serial_gate_pause.py
expected: PASS
- id: TC-11
type: unit
description: "Согласованность трёх точек: build_claim_clause, repo_has_active_task и _per_repo_snapshot дают один и тот же вердикт 'активна' на одном входе (анти-дрейф)."
module: tests/test_orch124_serial_gate_pause.py
expected: PASS
- id: TC-12
type: unit
description: "Hot-path offline: claim_next_job + build_claim_clause резолвят паузу без сетевого вызова (Plane API не вызывается); claim работает при недоступном Plane."
module: tests/test_orch124_serial_gate_pause.py
expected: PASS
- id: TC-13
type: unit
description: "never-raise / fail-directions: инъекция ошибки в логику паузы → build_claim_clause fail-OPEN ('' фрагмент, очередь не клинит), freeze-решение fail-CLOSED; ни одна публичная функция не бросает."
module: tests/test_orch124_serial_gate_pause.py
expected: PASS
- id: TC-14
type: unit
description: "Kill-switch / нейтральность: при выключенном под-флаге паузы (и/или serial_gate_enabled) поведение serial-gate байт-в-байт как ORCH-088/090; вне области репо — не затронуто (enduro)."
module: tests/test_orch124_serial_gate_pause.py
expected: PASS
- id: TC-15
type: unit
description: "Структурный анти-регресс: STAGE_TRANSITIONS, состав QG_CHECKS, имена check_* и machine-verdict ключи не изменены; схемы существующих таблиц tasks/jobs/job_deps/repo_freeze не сломаны."
module: tests/test_orch124_serial_gate_pause.py
expected: PASS

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>

View File

@@ -0,0 +1,150 @@
---
verdict: REQUEST_CHANGES
work_item: ORCH-124
stage: review
author_agent: reviewer
status: changes-requested
created_at: 2026-06-16
model_used: claude-opus-4-8
type: review
work_item_id: ORCH-124
version: 2
---
# Review ORCH-124 — serial-gate «пауза без блокировки» (per-task park-сигнал)
## Summary
Багфикс (метка `Bug`, эскалирован в full-cycle) инцидента ORCH-116/ORCH-123: serial-gate считал
«активной» любую задачу со стадией вне `{done,cancelled}`, а Plane-статусы Backlog/Blocked/Needs-Input
(слой B) не меняют `tasks.stage` (слой A) ⇒ приостановленный предшественник держал FIFO-гейт закрытым
против срочного успешника. Решение — **ортогональная ось планировщика «пауза»**: аддитивная колонка
`tasks.paused_at TEXT` + терм `AND paused_at IS NULL` во всех 3 точках определения «активной задачи»,
операторские эндпоинты `POST /serial-gate/pause|resume`, под независимым под-флагом
`serial_gate_pause_enabled`.
**Ядро реализации (код + ТЗ + ADR + тесты) — высокого качества и P0/P1-замечаний по сути фикса не
имеет.** Реализация 1:1 соответствует ADR-001 (D1D9), закрывает все FR/AC, машинные инварианты не
тронуты, полный прогон `pytest tests/` зелёный (**2178 passed** на текущем HEAD; ранее красный
order-dependent `test_orch123::test_r2…` исправлен изоляцией `repos_dir` в коммите `3a19728`).
**Но есть один P1 по оси документации:** PR меняет описанную в **витрине системы**
(`docs/overview/tech-pipeline.md`) функциональность serial-gate (маршрут задач внутри репо), но
витрину **не обновил** — нарушение норматива сопровождения `docs/overview/README.md` и правила агентов
§6 (ORCH-011/ORCH-079). Предыдущий ревью (run 766) ошибочно заключил «serial-gate не фигурирует в
витрине»; по факту serial-gate описан в 4 файлах витрины, и `tech-pipeline.md` несёт **абсолютное
утверждение FIFO**, которое ORCH-124 делает неполным. **Вердикт: REQUEST_CHANGES** (исправляется
обновлением витрины в этом же PR).
## Проверка по осям
### 1. Соответствие ТЗ (`02-trz.md` / `03-acceptance-criteria.md`) — ✅
- FR-1 (пауза исключает из горячего SQL): `build_claim_clause` — терм `AND t2.paused_at IS NULL` внутри
существующего `EXISTS`, offline, fail-OPEN сохранён (внутри того же try/except). ✓
- FR-2 (зеркало + снапшот согласованы): один предикат во всех 3 точках; анти-дрейф — **TC-11**. ✓
- FR-3 (явное durable DB-намерение): колонка `paused_at` + `set/clear/is_task_paused` + эндпоинты;
durable через рестарт — **TC-06**. ✓
- FR-4 (анти-stale-base при resume): через существующие механизмы (D8), без новой rebase-машинерии. ✓
- FR-5 (причина ожидания): `_waiting_reason` (`freeze``dependency``active-task``null`) + ключ
`paused`**TC-10**. ✓
- FR-6 (явные блоки сохранены): пауза НЕ обходит `repo_freeze`/`task_deps`**TC-08/TC-09**
(подтверждено: `task_deps` читает только терминал `{done,cancelled}`, не `paused_at`). ✓
- FR-7 (условность/нейтральность): под-флаг + scope `serial_gate_repos`; kill-switch off байт-в-байт —
**TC-14**. ✓
- AC-1…AC-10 покрыты TC-01…TC-15 буквально по файлам/тестам; прогон зелёный.
### 2. Соответствие ADR (`06-adr/ADR-001`, сквозной `adr-0051`) — ✅
Реализация = ADR 1:1: D1 (явный per-task флаг), D2 (`tasks.paused_at` через `_ensure_column`,
паттерн `cancelled_at`/`track`), D3 (ортогональная ось — терминал `{done,cancelled}` **байт-в-байт**),
D4 (3 точки согласованно), D5 (наблюдаемость), D6 (`serial_gate_pause_enabled`), D7 (эндпоинты),
D8 (анти-stale-base реюзом), D9 (never-raise/fail-directions).
- **Трассировка (ORCH-078/TRACEABILITY):** правка горячего SQL с маркерами `ORCH-088`/`ORCH-090`
инвариант FIFO (`t2.id < jobs.task_id`, R-7) и терминал-множество `{done,cancelled}` (adr-0026) **не
сломаны** (структурный **TC-15** + сверка: `src/task_deps.py`/`src/stages.py` `paused_at` не читают).
Маркер `ORCH-124` размещён рядом; сводный сквозной `adr-0051` заведён. ✓
- Нет нарушений глобальных ADR. `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схемы
существующих таблиц — не тронуты (`src/stages.py`, `src/qg/`, `src/frontmatter.py` вне диффа —
подтверждено по `git diff --name-only`). ✓
### 3. Качество кода — ✅
- Чисто, читаемо; docstrings на всех новых публичных функциях; маркеры/ссылки на ADR в коде.
- never-raise на всех публичных функциях `serial_gate`/`db`-хелперах; hot-claim fail-OPEN,
freeze fail-CLOSED — направления не инвертированы (**TC-13**, инъекция ошибки в `_pause_layer_enabled`
`build_claim_clause()==""` → claim не падает).
- Helper-сигнатуры выверены по коду: `db.get_task_by_work_item_id` существует (`SELECT *``paused_at`
попадает в `dict`), `notifications.link_for`/`send_telegram` существуют. `resume`-эндпоинт сознательно
НЕ гейтится `_pause_layer_enabled()` (позволяет снять «залипшую» паузу после выключения слоя) —
корректное решение, не дефект.
- **Багфикс-трек (ORCH-019 BR-4) — выполнен:** обязательный регресс-тест-фиксатор **TC-01**
воспроизводит инцидент (красный до фикса — опирается на отсутствовавшие `set_task_paused`/`paused_at`;
зелёный после); «до-фикса»-поведение бага дополнительно зафиксировано в **TC-14** (kill-switch off →
паузнутая задача снова блокирует). ✓
- Тесты содержательные (15 TC, не тривиальные): регресс, анти-регресс ORCH-088, durable/restart,
resume, сохранность freeze/dependency, анти-дрейф 3 точек, offline hot-path, never-raise,
kill-switch-нейтральность, структурный анти-регресс реестров/схем.
- **Тест-гигиена (коммит `3a19728`):** изоляция `settings.repos_dir` в фикстуре
`tests/test_orch123_staging_runner_exec.py` устранила order-dependent FAIL `test_r2…` (из-за фолбэка
`check_staging_status` на реальный `<repos_dir>/orchestrator/.../15-staging-log.md` после мержа
ORCH-123). Инвариант ORCH-123 R-2 сохранён; правка только теста — корректно. Чуть вне строгой области
ORCH-124, но прозрачно задокументирована в CHANGELOG. Не блокирует.
### 4. Документация (обязательная ось) — ⚠️ обновлена НЕПОЛНО (см. P1)
-`docs/architecture/README.md` (раздел serial-gate + ось «пауза», таблица БД `paused_at`, таблица
API `/serial-gate/pause|resume`), `docs/architecture/internals.md` (ось «пауза» ⊥ «терминальность»),
`CHANGELOG.md` (развёрнуто), `.env.example` (`ORCH_SERIAL_GATE_PAUSE_ENABLED`), ADR `06-adr/ADR-001` +
сквозной `adr-0051`, `08-data-requirements.md` — обновлены качественно.
-**`docs/overview/` (витрина системы) НЕ обновлена** при изменении описанного в ней маршрута задач
serial-gate → **P1** (детали ниже). serial-gate присутствует в витрине (grep: `tech-pipeline.md`,
`tech-data-model.md`, `tech-observability.md`, `tech-quality-security.md`).
- ✅ Root `README.md` «Известные ограничения»: п.3 — про эпик ORCH-088 (пакетный автоном), данным
багфиксом не закрывается → обновления не требует.
## Findings
### P0 — Blocker
- Нет.
### P1 — Must fix
- [ ] **Витрина системы `docs/overview/` не обновлена при изменении описанной в ней функциональности
serial-gate (ORCH-011/ORCH-079, правило агентов §6 + норматив `docs/overview/README.md`).**
`docs/overview/tech-pipeline.md` §«Последовательность внутри репозитория (serial gate)» (стр. 103108)
несёт **абсолютное** утверждение маршрута: *«Новая задача репозитория не входит в работу, пока не
завершена более ранняя (FIFO)»* и уже **перечисляет исключение freeze** («следующие задачи ждут»).
ORCH-124 вводит **второе намеренное исключение** — оператор может поставить предшественника на паузу,
и срочный успешник его **обгоняет**. После фикса утверждение FIFO неполно/вводит в заблуждение, а
норматив сопровождения витрины (`docs/overview/README.md`: «маршруты задач → `tech-pipeline.md`»)
требует синхронного обновления в том же PR.
**Как исправить:** добавить в `tech-pipeline.md` (раздел serial gate) краткую фразу про ось «пауза
без блокировки» (оператор паузит предшественника → срочный успешник обгоняет; пауза ≠ cancel, не
обходит freeze/dependency), рядом с уже описанным исключением freeze. Рекомендуется заодно (та же ось,
во избежание повторного REQUEST_CHANGES): `tech-data-model.md` — упомянуть durable-сигнал
`tasks.paused_at` рядом с `repo_freeze`; `tech-observability.md` — ключ `paused`/`reason` в блоке
`serial_gate` `GET /queue` и операторские эндпоинты `pause|resume`. Машинные доковые тесты
(`test_system_docs.py`) сейчас зелёные — это пробел **точности прозы**, который машинно не ловится.
### P2 — Should fix
- [ ] **Мусорные хвостовые теги `</content>` / `</invoke>` в 4 golden-source доках этого PR.**
Литерально закоммичены в конце файлов: `docs/work-items/ORCH-124/06-adr/ADR-001-serial-gate-pause-without-blocking.md`
(стр. 299300), `docs/architecture/adr/adr-0051-serial-gate-pause-without-blocking.md` (стр. 111),
`docs/work-items/ORCH-124/08-data-requirements.md` (стр. 54), `docs/work-items/ORCH-124/10-tech-risks.md`
(стр. 41) — протёкшие в контент закрывающие XML-теги tool-call'а архитектора. Frontmatter цел, машинный
парсинг не страдает, но golden-source доки не должны нести этот мусор. (Системный pre-existing паттерн —
52 файла по репо, в т.ч. в уже смерженном ORCH-114 → отдельная гигиеническая зачистка уместна; но 4
файла **этого** PR следует почистить здесь.)
### P3 — Nice-to-have (не блокирует)
- [ ] HTTP-уровневых тестов для `POST /serial-gate/pause|resume` нет (терминальный no-op / под-флаг-off
no-op / success+telegram). Не блокирует: тонкая обёртка над полностью покрытыми `db.set/clear/is_task_paused`,
а SQL-предикат гейта протестирован исчерпывающе; соответствует конвенции репо (соседние операторские
эндпоинты `/serial-gate/unfreeze`, `/transition-lease/release` тоже без HTTP-тестов).
- [ ] `.task-dev.md` (dev-скретч) закоммичен с метаданными ORCH-124 — pre-existing трекаемый паттерн,
вне области ORCH-124; кандидат на `.gitignore` отдельной задачей.
## Документация
`src/` изменён (`config`/`db`/`main`/`serial_gate`). Golden-source **инженерные** доки (`README.md`
архитектуры, `internals.md`, `CHANGELOG.md`, `.env.example`), оба ADR (work-item + сквозной `adr-0051`)
и `08-data-requirements.md` обновлены синхронно и качественно. **Однако обзорная витрина `docs/overview/`
(`tech-pipeline.md`, обязательно; `tech-data-model.md`/`tech-observability.md`, рекомендуется) НЕ
обновлена**, хотя ORCH-124 меняет описанный в ней маршрут задач serial-gate — это P1 (ORCH-011/ORCH-079).
Документация = golden source наравне с кодом; до устранения P1 — `REQUEST_CHANGES`. После добавления
оси «пауза» в витрину (и зачистки P2-тегов) ядро фикса готово к приёмке.

View File

@@ -0,0 +1,46 @@
---
result: FAIL
work_item: ORCH-124
stage: testing
author_agent: test-runner
status: failed
created_at: 2026-06-16
model_used: n/a
exit_code: 1
smoke: skipped
---
# Test Gate Log (deterministic runner, ORCH-116)
pytest exit-code `1` -> `result: FAIL` (smoke: skipped).
Вердикт зафиксирован детерминированным test-раннером (ORCH-116), не LLM. PASS/FAIL = exit-код `pytest` + read-only smoke (`/health`, `/status`, `/queue` + блок `serial_gate`).
pytest stdout (tail):
```
onkeypatch.MonkeyPatch object at 0x7f3dd2a3bd70>
def test_r2_held_deploy_staging_not_rolled_back(monkeypatch):
tid = _make_task("deploy-staging")
# No 15-staging-log.md was written (infra-HOLD) -> check_staging_status is red.
advance = MagicMock()
monkeypatch.setattr(stage_engine, "advance_stage", advance)
result = stage_engine.advance_if_gate_passed(
tid, "deploy-staging", "orchestrator", "ORCH-123", "feature/ORCH-123-x"
)
> assert result is None # red gate -> stay, no advance call
E AssertionError: assert <MagicMock name='mock()' id='139903524445248'> is None
tests/test_orch123_staging_runner_exec.py:462: AssertionError
=============================== warnings summary ===============================
src/config.py:8
/repos/_wt/orchestrator/feature_ORCH-124-bug-serial-gate-treats-backlog/src/config.py:8: PydanticDeprecatedSince20: Support for class-based `config` is deprecated, use ConfigDict instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.13/migration/
class Settings(BaseSettings):
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
=========================== short test summary info ============================
FAILED tests/test_orch123_staging_runner_exec.py::test_r2_held_deploy_staging_not_rolled_back
1 failed, 2177 passed, 1 warning in 100.82s (0:01:40)
```

View File

@@ -1001,9 +1001,22 @@ class Settings(BaseSettings):
# layer (env ORCH_SERIAL_GATE_FREEZE_ENABLED). False
# -> freeze is neither set (post-deploy DEGRADED) nor
# consulted in the claim gate.
# serial_gate_pause_enabled -> ORCH-124 (adr-0051 D6): independent tumbler for
# the per-task "park" axis (env
# ORCH_SERIAL_GATE_PAUSE_ENABLED). True (default) ->
# a task with tasks.paused_at NOT NULL is excluded
# from the serial-gate "active task" predicate so an
# URGENT successor may overtake a paused predecessor.
# Default is a TRUE no-op until an operator pauses a
# task (paused_at is NULL for all rows). False ->
# pause-term omitted, serial-gate is byte-for-byte
# ORCH-088/090 (deliberate rollback). Scope reuses
# serial_gate_repos (no new *_repos flag); subordinate
# to the serial_gate_enabled kill-switch.
serial_gate_enabled: bool = True
serial_gate_repos: str = ""
serial_gate_freeze_enabled: bool = True
serial_gate_pause_enabled: bool = True
# ORCH-090: STOP-status task cancellation (stop active agent + full progress
# reset) and the relaunch-hole close. A new logical Plane key `stop` (fail-closed,

100
src/db.py
View File

@@ -147,6 +147,17 @@ def init_db():
# after a successful atomic create). Read in advance_stage for the routing-override
# (skips architecture) — from the DB, NEVER from the network (NFR-4).
_ensure_column(conn, "tasks", "track", "TEXT DEFAULT 'full'")
# ORCH-124 (08-data-requirements.md, ADR-001 D2): per-task durable "park"
# signal for the serial gate. Additive, idempotent (_ensure_column is a no-op
# once present) -> safe on the live shared prod DB (enduro untouched), exactly
# like tasks.cancelled_at / tasks.cancel_requested_at / tasks.track above.
# paused_at -> NULL = not paused; ISO timestamp (datetime('now')) = an
# operator explicitly parked the task (POST /serial-gate/pause).
# Read ONLY by the serial-gate "active task" predicate (ORTHOGONAL to the
# {done,cancelled} terminal axis — task_deps/stages.py do NOT read it, adr-0026
# is untouched). All existing rows default to NULL -> pre-ORCH-124 behaviour
# holds until the first explicit operator pause.
_ensure_column(conn, "tasks", "paused_at", "TEXT")
# ORCH-026 (Level B): declarative task dependencies. job_deps stores the
# directed edge "task_id (B) is blocked-by depends_on_task_id (A)". The
# scheduler gate in claim_next_job keeps B queued until every A reaches
@@ -776,6 +787,95 @@ def get_task_track(task_id: int) -> str:
return "full"
# ---------------------------------------------------------------------------
# ORCH-124: serial-gate per-task park signal (tasks.paused_at) helpers
# ---------------------------------------------------------------------------
def set_task_paused(task_id: int) -> bool:
"""ORCH-124 (ADR-001 D7): park a task for the serial gate (idempotent).
Stamps ``tasks.paused_at=datetime('now')`` so the serial-gate "active task"
predicate stops counting this task as a FIFO blocker (an URGENT successor may
overtake it). Durable (survives restart) and DB-resolvable — the hot-claim SQL
reads it locally without any network call. Re-pausing an already-paused task
keeps the original timestamp (``WHERE paused_at IS NULL``), so the park moment
is stable. never-raise -> False on error (a write failure must not crash the
operator endpoint / worker).
"""
if task_id is None:
return False
try:
conn = get_db()
try:
conn.execute(
"UPDATE tasks SET paused_at=datetime('now') "
"WHERE id=? AND paused_at IS NULL",
(task_id,),
)
conn.commit()
finally:
conn.close()
return True
except Exception as e: # noqa: BLE001 - never-raise
import logging
logging.getLogger("orchestrator.db").warning(
"set_task_paused error for task %s: %s", task_id, e
)
return False
def clear_task_paused(task_id: int) -> bool:
"""ORCH-124 (ADR-001 D7): resume a parked task (idempotent).
Clears ``tasks.paused_at`` back to NULL so the task re-enters the serial-gate
FIFO (holds the gate as active again, or re-enters with a deferred branch cut —
see ADR-001 D8). Resuming a task that is not paused is a no-op. never-raise ->
False on error.
"""
if task_id is None:
return False
try:
conn = get_db()
try:
conn.execute(
"UPDATE tasks SET paused_at=NULL WHERE id=?",
(task_id,),
)
conn.commit()
finally:
conn.close()
return True
except Exception as e: # noqa: BLE001 - never-raise
import logging
logging.getLogger("orchestrator.db").warning(
"clear_task_paused error for task %s: %s", task_id, e
)
return False
def is_task_paused(task_id: int) -> bool:
"""ORCH-124: read whether a task is currently parked; missing/error -> False.
Conservative fail direction (ADR-001 D9): on any read error we report "not
paused" so the task is treated as active -> the serial gate stays CLOSED rather
than wrongly opening (anti-stale-base safe). Mirror of ``get_task_track``.
"""
if task_id is None:
return False
try:
conn = get_db()
try:
row = conn.execute(
"SELECT paused_at FROM tasks WHERE id=?", (task_id,)
).fetchone()
finally:
conn.close()
if not row:
return False
return row["paused_at"] is not None
except Exception: # noqa: BLE001 - conservative: not paused -> stays active
return False
# ---------------------------------------------------------------------------
# Telegram live tracker helpers (feat/telegram-live-tracker)
# ---------------------------------------------------------------------------

View File

@@ -376,6 +376,84 @@ async def serial_gate_unfreeze(repo: str = ""):
return {"ok": True, "repo": repo, "cleared": cleared, "frozen": frozen}
@app.post("/serial-gate/pause")
async def serial_gate_pause(work_item: str = ""):
"""ORCH-124 (adr-0051 D7): park a task so the serial gate stops counting it as
an active FIFO blocker — an urgent successor may overtake it.
Explicit, durable, DB-resolvable operator intent (NOT a Plane-status gesture):
stamps ``tasks.paused_at`` so the offline hot-claim SQL reads it locally without
a network call. Pause does NOT bypass a ``repo_freeze`` or a declared dependency
(different axes) and is NOT terminal (distinct from STOP/cancel). By образцу
``POST /serial-gate/unfreeze``; never-raise. Pausing a terminal (done/cancelled)
task is a no-op. When the pause sub-flag is off the call is a no-op + warning
(the pause-term is omitted from the gate, so a column write would be latent).
"""
from . import db
from . import serial_gate
if not work_item or not work_item.strip():
return {"ok": False, "error": "missing 'work_item'", "work_item": work_item}
work_item = work_item.strip()
if not serial_gate._pause_layer_enabled():
return {"ok": False, "error": "serial_gate_pause_enabled is off (no-op)",
"work_item": work_item}
task = db.get_task_by_work_item_id(work_item)
if not task:
return {"ok": False, "error": "unknown work_item", "work_item": work_item}
task_id = task["id"]
stage = task.get("stage")
if stage in ("done", "cancelled"):
return {"ok": False, "error": f"task is terminal (stage={stage})",
"work_item": work_item, "task_id": task_id, "stage": stage}
ok = db.set_task_paused(task_id)
refreshed = db.get_task_by_work_item_id(work_item) or {}
paused_at = refreshed.get("paused_at")
if ok:
try:
from .notifications import send_telegram, link_for
send_telegram(
f"⏸️ {link_for(work_item)}: задача поставлена на ПАУЗУ для serial-gate "
f"(task {task_id}, stage={stage}). Срочный успешник репо может обогнать; "
f"resume — POST /serial-gate/resume."
)
except Exception:
pass
return {"ok": ok, "work_item": work_item, "task_id": task_id,
"stage": stage, "paused_at": paused_at}
@app.post("/serial-gate/resume")
async def serial_gate_resume(work_item: str = ""):
"""ORCH-124 (adr-0051 D7 / AC-10): resume a parked task — it re-enters the
serial gate (holds it as active again / re-enters FIFO with the deferred branch
cut, D8). Inverse of ``POST /serial-gate/pause``; idempotent (resuming a task
that is not paused clears nothing). Anti-stale-base on resume is guaranteed by
the EXISTING mechanisms (deferred branch cut + pre-merge auto_rebase_onto_main +
merge-gate re-test, ORCH-088/093/110) — no new rebase machinery. never-raise.
"""
from . import db
if not work_item or not work_item.strip():
return {"ok": False, "error": "missing 'work_item'", "work_item": work_item}
work_item = work_item.strip()
task = db.get_task_by_work_item_id(work_item)
if not task:
return {"ok": False, "error": "unknown work_item", "work_item": work_item}
task_id = task["id"]
was_paused = task.get("paused_at") is not None
ok = db.clear_task_paused(task_id)
if ok and was_paused:
try:
from .notifications import send_telegram, link_for
send_telegram(
f"▶️ {link_for(work_item)}: задача СНЯТА С ПАУЗЫ (task {task_id}) — "
f"снова участвует в serial-gate."
)
except Exception:
pass
return {"ok": ok, "work_item": work_item, "task_id": task_id,
"was_paused": was_paused, "paused_at": None}
@app.post("/transition-lease/release")
async def transition_lease_release(work_item: str = ""):
"""ORCH-114 (adr-0045 / D10): operator manual reclaim of a stuck transition-lease.

View File

@@ -23,6 +23,16 @@ Two deliberately different failure directions (ADR-001 D10, NFR-1):
must not wedge the queue of ALL projects (AC-8).
* freeze decision (``is_repo_frozen``) -> fail-CLOSED (``True``): when we cannot
confirm the ABSENCE of a freeze we keep the gate closed for prod safety (AC-9).
ORCH-124 (adr-0051): adds an ORTHOGONAL "pause" axis to the "active task" predicate
of all three points (``build_claim_clause`` / ``repo_has_active_task`` /
``_per_repo_snapshot``). A task with ``tasks.paused_at`` NOT NULL (an operator
``POST /serial-gate/pause``) is excluded from the FIFO "active" set so an URGENT
successor may overtake a paused predecessor — fixing incident ORCH-116/ORCH-123. The
terminal set ``{done,cancelled}`` (adr-0026) is UNCHANGED; ``task_deps`` / ``stages.py``
do NOT read ``paused_at`` (pause never bypasses a freeze or a declared dependency).
Gated by the independent sub-flag ``serial_gate_pause_enabled`` (default True is a true
no-op until the first explicit pause).
"""
from __future__ import annotations
@@ -97,6 +107,22 @@ def _freeze_layer_enabled() -> bool:
return False
def _pause_layer_enabled() -> bool:
"""ORCH-124 (adr-0051 D6): whether the per-task pause axis is active.
Independent tumbler ``serial_gate_pause_enabled`` (mirror of
``_freeze_layer_enabled``). When True the "active task" predicate of all three
serial-gate points additionally excludes paused tasks (``paused_at IS NULL``);
when False the pause-term is omitted and serial-gate behaves byte-for-byte as
ORCH-088/090. Default True is a true no-op until an operator parks a task
(``paused_at`` is NULL for every row). never-raise -> False (pause inert).
"""
try:
return bool(getattr(settings, "serial_gate_pause_enabled", False))
except Exception: # noqa: BLE001
return False
# ---------------------------------------------------------------------------
# Read helpers (active task + freeze) — only the local DB
# ---------------------------------------------------------------------------
@@ -113,16 +139,21 @@ def repo_has_active_task(repo: str, exclude_task_id: int | None = None) -> bool:
# ORCH-090 (adr-0026): terminal set is {done,cancelled}. A cancelled
# task must NOT count as "active" or it would block the repo's serial
# gate forever.
# ORCH-124 (adr-0051 D4.2): under the pause layer a PARKED task
# (paused_at NOT NULL) is likewise NOT "active" — it must not hold the
# FIFO gate against an urgent successor. Same predicate as the hot SQL
# (D4.1) and the snapshot (D4.3) so the three points never drift (TR-7).
pause_term = " AND paused_at IS NULL" if _pause_layer_enabled() else ""
if exclude_task_id is not None:
row = conn.execute(
"SELECT 1 FROM tasks WHERE repo=? AND id != ? "
"AND stage NOT IN ('done','cancelled') LIMIT 1",
f"AND stage NOT IN ('done','cancelled'){pause_term} LIMIT 1",
(repo, exclude_task_id),
).fetchone()
else:
row = conn.execute(
"SELECT 1 FROM tasks WHERE repo=? "
"AND stage NOT IN ('done','cancelled') LIMIT 1",
f"AND stage NOT IN ('done','cancelled'){pause_term} LIMIT 1",
(repo,),
).fetchone()
return row is not None
@@ -271,10 +302,18 @@ def build_claim_clause() -> str:
repo_scope = ""
# ORCH-090 (adr-0026): {done,cancelled} are both terminal — an EARLIER
# cancelled task no longer holds the FIFO serial gate closed.
# ORCH-124 (adr-0051 D4.1): under the pause layer an EARLIER PARKED task
# (paused_at NOT NULL) also no longer holds the FIFO gate — an urgent
# successor may overtake it. The pause-term is appended INSIDE the existing
# EXISTS subquery (no extra JOIN/EXISTS), reads only the local DB (offline
# hot path, NFR-2), and is built inside the same try/except so any error in
# the pause sub-expression still fails-OPEN (D9). pause off / kill-switch ->
# pause_term is "" -> the clause is byte-for-byte ORCH-088/090.
pause_term = " AND t2.paused_at IS NULL" if _pause_layer_enabled() else ""
active_clause = (
"EXISTS (SELECT 1 FROM tasks t2 "
"WHERE t2.repo = jobs.repo AND t2.id < jobs.task_id "
"AND t2.stage NOT IN ('done','cancelled')) "
f"AND t2.stage NOT IN ('done','cancelled'){pause_term}) "
)
if _freeze_layer_enabled():
freeze_clause = (
@@ -329,23 +368,91 @@ def _known_repos() -> list[str]:
return sorted(repos)
def _waiting_reason(conn, repo: str, task_id: int | None, *,
frozen: bool, pause_on: bool, deps_on: bool) -> str | None:
"""ORCH-124 (adr-0051 D5): why an analyst-job is NOT claimable, or None.
Priority order (matches the precedence of the actual claim gates):
``freeze`` (active repo_freeze) -> ``dependency`` (an unfinished declared
job_deps predecessor, only when task_deps is on) -> ``active-task`` (an EARLIER
NON-paused unfinished task holds the FIFO gate) -> ``None`` (claimable). A
paused predecessor is deliberately NOT a reason — by design it does NOT block,
so it surfaces only via the snapshot's ``paused`` list, never here. never-raise
-> None on error (observability only, conservative).
"""
try:
if frozen:
return "freeze"
if deps_on and task_id is not None:
dep = conn.execute(
"SELECT 1 FROM job_deps d JOIN tasks t ON t.id = d.depends_on_task_id "
"WHERE d.task_id = ? AND t.stage NOT IN ('done','cancelled') LIMIT 1",
(task_id,),
).fetchone()
if dep is not None:
return "dependency"
if task_id is not None:
pause_term = " AND paused_at IS NULL" if pause_on else ""
earlier = conn.execute(
"SELECT 1 FROM tasks WHERE repo=? AND id < ? "
f"AND stage NOT IN ('done','cancelled'){pause_term} LIMIT 1",
(repo, task_id),
).fetchone()
if earlier is not None:
return "active-task"
return None
except Exception: # noqa: BLE001 - observability only
return None
def _per_repo_snapshot(repo: str) -> dict:
"""Per-repo gate state for the /queue snapshot (never raises here)."""
active_task = None
waiting: list[dict] = []
paused: list[dict] = []
# ORCH-124 (adr-0051 D5): compute frozen up-front so the per-job reason can be
# derived in the same pass. is_repo_frozen uses its own connection (separate
# from the snapshot conn below).
frozen = is_repo_frozen(repo)
pause_on = _pause_layer_enabled()
try:
deps_on = bool(getattr(settings, "task_deps_enabled", False))
except Exception: # noqa: BLE001
deps_on = False
try:
conn = db.get_db()
try:
# ORCH-090 (adr-0026): terminal set {done,cancelled}.
# ORCH-124 (adr-0051 D4.3): a PARKED task is excluded from active_task
# (same predicate as build_claim_clause / repo_has_active_task — no
# drift, TR-7); it surfaces in the additive `paused` list instead.
pause_term = " AND paused_at IS NULL" if pause_on else ""
row = conn.execute(
"SELECT work_item_id, stage FROM tasks "
"WHERE repo=? AND stage NOT IN ('done','cancelled') ORDER BY id LIMIT 1",
f"WHERE repo=? AND stage NOT IN ('done','cancelled'){pause_term} "
"ORDER BY id LIMIT 1",
(repo,),
).fetchone()
if row:
active_task = {"work_item_id": row["work_item_id"], "stage": row["stage"]}
# ORCH-124: additive `paused` list — non-terminal parked tasks of the
# repo (visible, but NOT counted as active_task). Only meaningful while
# the pause layer is on.
if pause_on:
for pr in conn.execute(
"SELECT work_item_id, stage, paused_at FROM tasks "
"WHERE repo=? AND stage NOT IN ('done','cancelled') "
"AND paused_at IS NOT NULL ORDER BY id",
(repo,),
).fetchall():
paused.append({
"work_item_id": pr["work_item_id"],
"stage": pr["stage"],
"paused_at": pr["paused_at"],
})
for j in conn.execute(
"SELECT j.id AS job_id, t.work_item_id AS work_item_id, t.stage AS stage "
"SELECT j.id AS job_id, j.task_id AS task_id, "
"t.work_item_id AS work_item_id, t.stage AS stage "
"FROM jobs j LEFT JOIN tasks t ON t.id = j.task_id "
"WHERE j.repo=? AND j.status='queued' AND j.agent='analyst' "
"ORDER BY j.id",
@@ -355,12 +462,17 @@ def _per_repo_snapshot(repo: str) -> dict:
"job_id": j["job_id"],
"work_item_id": j["work_item_id"],
"stage": j["stage"],
# ORCH-124 (D5): why this job is held (freeze/dependency/
# active-task) or None when claimable.
"reason": _waiting_reason(
conn, repo, j["task_id"],
frozen=frozen, pause_on=pause_on, deps_on=deps_on,
),
})
finally:
conn.close()
except Exception as e: # noqa: BLE001
logger.warning("serial_gate per-repo snapshot error for %s: %s", repo, e)
frozen = is_repo_frozen(repo)
frozen_reason = None
frozen_at = None
if frozen:
@@ -374,6 +486,8 @@ def _per_repo_snapshot(repo: str) -> dict:
return {
"active_task": active_task,
"waiting": waiting,
# ORCH-124 (D5): additive — parked predecessors (not shown as active_task).
"paused": paused,
"frozen": frozen,
"frozen_reason": frozen_reason,
"frozen_at": frozen_at,

View File

@@ -50,6 +50,13 @@ def fresh_db(monkeypatch, tmp_path):
os.unlink(_test_db)
init_db()
monkeypatch.setattr("src.git_worktree.settings.worktrees_dir", str(tmp_path), raising=False)
# Isolate repos_dir too: check_staging_status falls back to <repos_dir>/<repo>
# (and origin/main on it) when the feature worktree is absent. Without this the
# gate would read REAL on-disk artifacts from the shared clone (e.g. a merged
# ORCH-123/15-staging-log.md), turning the intended-RED gate in test_r2 green and
# making the suite order-dependent. Point it at an empty tmp subdir (no .git, no
# work-items) so the staging gate is deterministically "not found" -> red.
monkeypatch.setattr(cfg.settings, "repos_dir", str(tmp_path / "repos"), raising=False)
# Runner ON, self-hosting scope, host-side strategy ON (defaults).
monkeypatch.setattr(cfg.settings, "staging_runner_enabled", True, raising=False)
monkeypatch.setattr(cfg.settings, "staging_runner_repos", "", raising=False)

View File

@@ -0,0 +1,353 @@
"""ORCH-124 — serial-gate wait/pause semantics (real tmp SQLite, no network).
A paused predecessor must NOT block an urgent successor's analyst-job, while a
normally-executing predecessor still holds the FIFO gate (anti-stale-base ORCH-088
preserved). Covers 04-test-plan.yaml TC-01…TC-15. The behaviour (not the exact SQL)
is asserted: pause is an explicit, durable, DB-resolvable per-task signal
(``tasks.paused_at``) that the offline hot-claim SQL reads locally.
TC-01 REGRESS (mandatory): earlier PAUSED task A + later urgent B -> claim picks
B's analyst-job (gate open). Reproduces incident ORCH-116/ORCH-123.
TC-02 Predecessor parked (Backlog intent) -> build_claim_clause does NOT block B.
TC-03 Predecessor parked at another wait-stage (Needs-Input intent) -> still open.
TC-04 ANTI-REGRESS ORCH-088: a NON-paused unfinished predecessor STILL blocks B.
TC-05 Pause needs explicit durable intent; unpaused non-terminal task stays active.
TC-06 Durable: the pause signal survives a connection/restart (read from the DB).
TC-07 Resume restores participation in the gate (no eternal bypass).
TC-08 Explicit blocks kept: an active repo_freeze still gates B (pause != bypass).
TC-09 Explicit blocks kept: an unfinished declared dependency still gates B.
TC-10 /queue snapshot: paused task not shown as active_task; reason is correct.
TC-11 Three points agree on "active" (anti-drift): clause / mirror / snapshot.
TC-12 Hot-path offline: claim resolves pause with no network (Plane not consulted).
TC-13 never-raise / fail-directions: pause error -> build_claim_clause fail-OPEN.
TC-14 Kill-switch: pause sub-flag off -> byte-for-byte ORCH-088/090 (paused blocks).
TC-15 Structural anti-regress: STAGE_TRANSITIONS / QG_CHECKS / table schemas intact.
"""
import os
import tempfile
import pytest
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch124_pause.db")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
import src.db as db # noqa: E402
from src.db import init_db, get_db, enqueue_job, claim_next_job # noqa: E402
from src import serial_gate # noqa: E402
from src import config as cfg # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
dbfile = tmp_path / "pause.db"
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
# Serial gate ON; freeze layer ON; pause layer ON; empty CSV (all repos).
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", True, raising=False)
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "", raising=False)
monkeypatch.setattr(cfg.settings, "serial_gate_freeze_enabled", True, raising=False)
monkeypatch.setattr(cfg.settings, "serial_gate_pause_enabled", True, raising=False)
# Keep the unrelated dep-gate inert unless a test opts in.
monkeypatch.setattr(cfg.settings, "task_deps_enabled", False, raising=False)
init_db()
yield
def _make_task(work_item_id, stage="analysis", repo="orchestrator"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) "
"VALUES (?, ?, ?, ?, ?, ?)",
(work_item_id, work_item_id, repo, f"feature/{work_item_id}", stage, work_item_id),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
# --------------------------------------------------------------- TC-01
def test_paused_predecessor_does_not_block_urgent_successor():
"""REGRESS (ORCH-116/ORCH-123): earlier PAUSED A must not gate urgent B."""
a = _make_task("ORCH-116", stage="development") # earlier predecessor
b = _make_task("ORCH-123", stage="analysis") # later urgent task
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
# Before the pause A holds the FIFO gate -> B is blocked (the incident state).
assert claim_next_job() is None, "active A gates B (pre-pause, FIFO ORCH-088)"
# Operator parks A. Now B's analyst-job must become claimable.
assert db.set_task_paused(a) is True
claimed = claim_next_job()
assert claimed is not None and claimed["id"] == job_b, (
"a PAUSED predecessor must not gate the urgent successor (AC-1)"
)
# --------------------------------------------------------------- TC-02
def test_parked_backlog_predecessor_not_active_in_clause():
a = _make_task("ORCH-201", stage="analysis") # "Backlog" intent
b = _make_task("ORCH-202", stage="analysis")
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
db.set_task_paused(a)
assert "paused_at IS NULL" in serial_gate.build_claim_clause()
claimed = claim_next_job()
assert claimed is not None and claimed["id"] == job_b
# --------------------------------------------------------------- TC-03
def test_parked_needs_input_predecessor_not_active():
# Another wait-stage (review ~ "Needs-Input" intent) — same park column.
a = _make_task("ORCH-203", stage="review")
b = _make_task("ORCH-204", stage="analysis")
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
db.set_task_paused(a)
claimed = claim_next_job()
assert claimed is not None and claimed["id"] == job_b
# --------------------------------------------------------------- TC-04
def test_non_paused_predecessor_still_blocks_fifo():
"""ANTI-REGRESS ORCH-088: a normally-executing A still gates B."""
_make_task("ORCH-210", stage="development") # NOT paused
b = _make_task("ORCH-211", stage="analysis")
enqueue_job("analyst", "orchestrator", "B", task_id=b)
assert claim_next_job() is None, (
"a non-paused unfinished predecessor must STILL hold the gate (FIFO intact)"
)
# --------------------------------------------------------------- TC-05
def test_pause_requires_explicit_durable_intent():
a = _make_task("ORCH-215", stage="development")
b = _make_task("ORCH-216", stage="analysis")
enqueue_job("analyst", "orchestrator", "B", task_id=b)
# No explicit pause -> A is active -> gate held (no heuristic auto-pause).
assert db.is_task_paused(a) is False
assert claim_next_job() is None
# The pause signal is DB-resolvable once set explicitly.
db.set_task_paused(a)
assert db.is_task_paused(a) is True
# --------------------------------------------------------------- TC-06
def test_pause_signal_is_durable_across_restart():
a = _make_task("ORCH-220", stage="development")
b = _make_task("ORCH-221", stage="analysis")
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
db.set_task_paused(a)
# Simulate a restart: drop in-memory state, re-run the idempotent migration.
init_db()
assert db.is_task_paused(a) is True, "pause must survive restart (read from DB)"
claimed = claim_next_job()
assert claimed is not None and claimed["id"] == job_b
# --------------------------------------------------------------- TC-07
def test_resume_restores_gate_participation():
a = _make_task("ORCH-225", stage="development")
b = _make_task("ORCH-226", stage="analysis")
enqueue_job("analyst", "orchestrator", "B", task_id=b)
db.set_task_paused(a)
assert claim_next_job() is not None # B claimable while A paused
# Re-queue a fresh analyst-job for B (the previous one was claimed) and resume A.
conn = get_db()
conn.execute("UPDATE jobs SET status='queued', started_at=NULL WHERE task_id=?", (b,))
conn.commit()
conn.close()
assert db.clear_task_paused(a) is True
assert db.is_task_paused(a) is False
assert claim_next_job() is None, (
"after resume A holds the gate again — no eternal bypass (AC-10)"
)
# --------------------------------------------------------------- TC-08
def test_pause_does_not_bypass_freeze():
_make_task("ORCH-230", stage="done") # nothing unfinished
a = _make_task("ORCH-231", stage="development")
b = _make_task("ORCH-232", stage="analysis")
enqueue_job("analyst", "orchestrator", "B", task_id=b)
db.set_task_paused(a)
# Freeze the repo: even with A paused, B must stay blocked by the freeze.
serial_gate.set_repo_freeze("orchestrator", "DEGRADED", "ORCH-230")
assert claim_next_job() is None, "an active freeze gates B; pause must not bypass it"
# Clearing the freeze (A still paused) -> B becomes claimable.
serial_gate.clear_repo_freeze("orchestrator")
assert claim_next_job() is not None
# --------------------------------------------------------------- TC-09
def test_pause_does_not_bypass_declared_dependency(monkeypatch):
monkeypatch.setattr(cfg.settings, "task_deps_enabled", True, raising=False)
a = _make_task("ORCH-240", stage="development")
b = _make_task("ORCH-241", stage="analysis")
enqueue_job("analyst", "orchestrator", "B", task_id=b)
assert db.add_dependency(b, a) is True # B blocked-by A
db.set_task_paused(a)
# task_deps reads the {done,cancelled} terminal only (NOT paused_at): an
# unfinished declared dependency keeps B blocked even though A is paused.
assert claim_next_job() is None, (
"a declared unfinished dependency gates B; pause must not bypass it (AC-5)"
)
# Once A is terminal the dependency is satisfied -> B is claimable.
conn = get_db()
conn.execute("UPDATE tasks SET stage='done' WHERE id=?", (a,))
conn.commit()
conn.close()
assert claim_next_job() is not None
# --------------------------------------------------------------- TC-10
def test_snapshot_reason_and_paused_list():
a = _make_task("ORCH-250", stage="development")
b = _make_task("ORCH-251", stage="analysis")
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
# (a) A active (not paused) -> B waits with reason 'active-task'; A is active_task.
per = serial_gate.snapshot()["per_repo"]["orchestrator"]
assert per["active_task"]["work_item_id"] == "ORCH-250"
assert per["paused"] == []
wb = next(w for w in per["waiting"] if w["job_id"] == job_b)
assert wb["reason"] == "active-task"
# Existing keys preserved (BC).
assert set(per) >= {"active_task", "waiting", "frozen", "frozen_reason", "frozen_at"}
# (b) Pause A -> A no longer active_task; it appears in `paused`; B is claimable
# (reason None — a paused predecessor is by design NOT a wait reason).
db.set_task_paused(a)
per = serial_gate.snapshot()["per_repo"]["orchestrator"]
assert per["active_task"] is None or per["active_task"]["work_item_id"] != "ORCH-250"
assert any(p["work_item_id"] == "ORCH-250" for p in per["paused"])
wb = next(w for w in per["waiting"] if w["job_id"] == job_b)
assert wb["reason"] is None
# (c) Freeze -> reason 'freeze' (highest priority).
serial_gate.set_repo_freeze("orchestrator", "DEGRADED", "ORCH-250")
per = serial_gate.snapshot()["per_repo"]["orchestrator"]
wb = next(w for w in per["waiting"] if w["job_id"] == job_b)
assert wb["reason"] == "freeze"
# --------------------------------------------------------------- TC-11
def test_three_points_agree_on_active():
"""Anti-drift: clause / mirror / snapshot classify predecessor A identically.
B is excluded from the mirror (``exclude_task_id=b``) to mirror the clause's
own-row exclusion (``t2.id < jobs.task_id``), so the three points are asked the
SAME question: "does the non-B predecessor A count as an active blocker?".
"""
a = _make_task("ORCH-260", stage="development")
b = _make_task("ORCH-261", stage="analysis")
enqueue_job("analyst", "orchestrator", "B", task_id=b)
# A NOT paused -> all three say A is active.
assert serial_gate.repo_has_active_task("orchestrator", exclude_task_id=b) is True
assert (serial_gate.snapshot()["per_repo"]["orchestrator"]["active_task"]
["work_item_id"] == "ORCH-260")
assert claim_next_job() is None # clause blocks B on A
# A paused -> all three agree A is NOT active (consistent, no drift).
db.set_task_paused(a)
assert serial_gate.repo_has_active_task("orchestrator", exclude_task_id=b) is False
snap = serial_gate.snapshot()["per_repo"]["orchestrator"]
active = snap["active_task"]
assert active is None or active["work_item_id"] != "ORCH-260"
assert any(p["work_item_id"] == "ORCH-260" for p in snap["paused"])
assert claim_next_job() is not None # clause now opens for B
# --------------------------------------------------------------- TC-12
def test_hot_path_is_offline():
"""The pause predicate resolves from the local DB only — no network."""
a = _make_task("ORCH-270", stage="development")
b = _make_task("ORCH-271", stage="analysis")
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
db.set_task_paused(a)
# Functional: claim works with no Plane configured/reachable.
claimed = claim_next_job()
assert claimed is not None and claimed["id"] == job_b
# Structural: the gate leaf imports no network client (offline hot path, NFR-2).
import inspect
src = inspect.getsource(serial_gate)
for forbidden in ("import httpx", "import requests", "plane_sync", "urllib.request"):
assert forbidden not in src, f"serial_gate must stay offline (found {forbidden!r})"
# --------------------------------------------------------------- TC-13
def test_pause_error_fails_open_and_never_raises(monkeypatch):
_make_task("ORCH-280", stage="development") # would close the gate
b = _make_task("ORCH-281", stage="analysis")
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
def _boom():
raise RuntimeError("pause layer probe down")
monkeypatch.setattr(serial_gate, "_pause_layer_enabled", _boom, raising=True)
# build_claim_clause must fail-OPEN ('' fragment) — never raise, never wedge.
assert serial_gate.build_claim_clause() == ""
claimed = claim_next_job()
assert claimed is not None and claimed["id"] == job_b, (
"a pause-layer error must fail-OPEN, not wedge the queue (AC-9)"
)
# The other public functions degrade conservatively without raising.
assert serial_gate.repo_has_active_task("orchestrator") in (True, False)
assert isinstance(serial_gate.snapshot(), dict)
# Freeze direction is NOT inverted by a pause error (still fail-CLOSED on doubt).
monkeypatch.setattr(
serial_gate, "_active_freeze_row",
lambda repo: (_ for _ in ()).throw(RuntimeError("freeze read down")),
raising=True,
)
assert serial_gate.is_repo_frozen("orchestrator") is True
# The DB mutators/readers never raise on bad input either.
assert db.set_task_paused(None) is False
assert db.clear_task_paused(None) is False
assert db.is_task_paused(None) is False
# --------------------------------------------------------------- TC-14
def test_kill_switch_off_is_byte_for_byte_orch088(monkeypatch):
monkeypatch.setattr(cfg.settings, "serial_gate_pause_enabled", False, raising=False)
a = _make_task("ORCH-290", stage="development")
b = _make_task("ORCH-291", stage="analysis")
enqueue_job("analyst", "orchestrator", "B", task_id=b)
db.set_task_paused(a)
# Pause sub-flag OFF -> the pause-term is omitted -> a paused task STILL counts
# as active (deliberate ORCH-088/090 rollback behaviour).
assert "paused_at" not in serial_gate.build_claim_clause()
assert claim_next_job() is None, (
"with the pause sub-flag off serial-gate is byte-for-byte ORCH-088/090"
)
# Outside the (empty) repo scope nothing changes for enduro either.
et = _make_task("ET-290", stage="analysis", repo="enduro-trails")
job_et = enqueue_job("analyst", "enduro-trails", "B", task_id=et)
claimed = claim_next_job()
assert claimed is not None and claimed["id"] == job_et
# --------------------------------------------------------------- TC-15
def test_registries_and_schemas_unchanged():
from src.stages import STAGE_TRANSITIONS
from src.qg.checks import QG_CHECKS
# ORCH-124 is a scheduler-only change: no new edge, no new terminal sink.
assert set(STAGE_TRANSITIONS) == {
"created", "analysis", "architecture", "development", "review",
"testing", "deploy-staging", "deploy", "done", "cancelled",
}
# No serial-gate / pause QG check was introduced (the gate is a scheduler cond).
assert not any("serial" in k or "pause" in k for k in QG_CHECKS)
# Existing table schemas intact; tasks gained the additive paused_at column.
conn = get_db()
try:
task_cols = {r[1] for r in conn.execute("PRAGMA table_info(tasks)").fetchall()}
job_cols = {r[1] for r in conn.execute("PRAGMA table_info(jobs)").fetchall()}
dep_cols = {r[1] for r in conn.execute("PRAGMA table_info(job_deps)").fetchall()}
frz_cols = {r[1] for r in conn.execute("PRAGMA table_info(repo_freeze)").fetchall()}
finally:
conn.close()
assert "paused_at" in task_cols # additive
assert {"id", "repo", "stage", "work_item_id"}.issubset(task_cols)
assert {"id", "agent", "repo", "status", "task_id"}.issubset(job_cols)
assert {"task_id", "depends_on_task_id"}.issubset(dep_cols)
assert {"repo", "frozen_at", "cleared_at"}.issubset(frz_cols)