diff --git a/.env.example b/.env.example index ebe44ca..9e560f2 100644 --- a/.env.example +++ b/.env.example @@ -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=) 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) diff --git a/.task-dev.md b/.task-dev.md index 69f6747..327ea1c 100644 --- a/.task-dev.md +++ b/.task-dev.md @@ -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 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c74080..17cd6e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=` (стамп `paused_at`; терминальная/неизвестная задача → no-op-ответ; под-флаг off → no-op-предупреждение) и `POST /serial-gate/resume?work_item=` (сброс `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`. **Витрина системы `docs/overview/` (ORCH-011, синхронно в том же PR):** `tech-pipeline.md` (исключение FIFO «пауза без блокировки» рядом с freeze), `tech-data-model.md` (durable-сигнал `tasks.paused_at`), `tech-observability.md` (`paused`/`reason` в блоке `serial_gate` `GET /queue` + эндпоинты `pause|resume`). Зачищены протёкшие хвостовые теги tool-call (``/``) в 4 golden-source доках этого PR (`06-adr/ADR-001`, `adr-0051`, `08-data-requirements.md`, `10-tech-risks.md`). + - **Тест-гигиена (development-стадия, латентный регресс ORCH-123):** изолирован `settings.repos_dir` в фикстуре `tests/test_orch123_staging_runner_exec.py` (зеркально уже имевшейся изоляции `worktrees_dir`). `check_staging_status` при отсутствии фиче-worktree фолбэчит на `/` (и его `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-агента (~60–150k / 5–20 мин) и встраивал недетерминизм 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 ` **в 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). diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 4f41179..5e343f3 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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=` (по образцу + `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-` (`plane_id`/`work_item_id`/`plane_issue_id`) +- `tasks` — задачи и их стадии; колонки `cancelled_at`/`cancel_requested_at` (ORCH-090) — durable-метки STOP-отмены (вторая — отложенная отмена в критичном окне merge/deploy). Терминальная стадия `cancelled` (сток, параллельно `done`); натуральные ключи отменённой строки тумбстонятся суффиксом `#cancelled-` (`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=`) → `{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=`) → `{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=`) → `{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=`) → `{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}` | diff --git a/docs/architecture/adr/adr-0051-serial-gate-pause-without-blocking.md b/docs/architecture/adr/adr-0051-serial-gate-pause-without-blocking.md new file mode 100644 index 0000000..8ae73f1 --- /dev/null +++ b/docs/architecture/adr/adr-0051-serial-gate-pause-without-blocking.md @@ -0,0 +1,110 @@ +--- +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) diff --git a/docs/architecture/internals.md b/docs/architecture/internals.md index 0328e77..82c958a 100644 --- a/docs/architecture/internals.md +++ b/docs/architecture/internals.md @@ -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 | Метод проверки | diff --git a/docs/overview/tech-data-model.md b/docs/overview/tech-data-model.md index bf2cdf1..5710dd3 100644 --- a/docs/overview/tech-data-model.md +++ b/docs/overview/tech-data-model.md @@ -19,7 +19,8 @@ Project ──1:N──► Work-Item / Task ──1:N──► Job ──1:N─ ### Work-Item / Task — задача конвейера Строка таблицы `tasks`: текущая **стадия** (`stage`), **маршрут** (`track`: полный или -багфикс), рабочая **ветка**, счётчики откатов, отметки отмены. Натуральные ключи — ID задачи +багфикс), рабочая **ветка**, счётчики откатов, отметки отмены и **паузы** (`paused_at` — +durable-сигнал «пропустить меня в serial gate», не терминальный). Натуральные ключи — ID задачи в Plane и человекочитаемый номер (`ORCH-NNN`). На каждой стадии задача накапливает **артефакты** — номерные документы в `docs/work-items//` (от бизнес-запроса до deploy-лога; манифест — [PIPELINE_DOCS](../_standards/PIPELINE_DOCS.md)). diff --git a/docs/overview/tech-observability.md b/docs/overview/tech-observability.md index 0a10794..e9e428f 100644 --- a/docs/overview/tech-observability.md +++ b/docs/overview/tech-observability.md @@ -20,9 +20,10 @@ ## Служебные страницы платформы - **`GET /queue`** — человекочитаемый снимок всего конвейера: очередь и job'ы, состояние - serial gate и заморозок, авто-лейблы, багфикс-трек, coverage, журнал уроков, владение - переходами (`transition_lease`), фоновые демоны. Первая точка диагностики «что сейчас - происходит». + serial gate (заморозки, паузы задач, причина ожидания успешника), авто-лейблы, багфикс-трек, + coverage, журнал уроков, владение переходами (`transition_lease`), фоновые демоны. Первая + точка диагностики «что сейчас происходит». Паузу/возобновление задачи в serial gate оператор + включает явными эндпоинтами `POST /serial-gate/pause|resume`. - **`GET /metrics`** — машинный контракт для внешнего наблюдателя (версионированная схема): health, возраст последних событий, счётчики сбоев. - **`GET /health`** — живость процесса. diff --git a/docs/overview/tech-pipeline.md b/docs/overview/tech-pipeline.md index b7c678c..397891d 100644 --- a/docs/overview/tech-pipeline.md +++ b/docs/overview/tech-pipeline.md @@ -107,6 +107,13 @@ created → analysis → architecture → development → review → testing → прода после выкладки замораживает репозиторий (freeze) до ручного разбора — следующие задачи ждут. +У FIFO-порядка есть управляемое исключение — **пауза без блокировки**: оператор может явно +поставить более раннюю задачу на паузу (durable-сигнал `tasks.paused_at`), и тогда срочный +успешник её обгоняет, не дожидаясь завершения. Пауза — отдельная ось: она ≠ отмена (задача не +терминальна и возвращается в гейт обратной командой) и **не** обходит ни freeze, ни объявленные +зависимости. Свежесть базы возобновлённой задачи гарантируют те же механизмы (отложенный срез +ветки + ребейз на слиянии), что и для обычного FIFO. + ## Отмена: STOP → `cancelled` Перевод задачи в статус **STOP** останавливает агента, снимает job'ы с очереди, удаляет diff --git a/docs/work-items/ORCH-124/00-business-request.md b/docs/work-items/ORCH-124/00-business-request.md new file mode 100644 index 0000000..f4056a4 --- /dev/null +++ b/docs/work-items/ORCH-124/00-business-request.md @@ -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 diff --git a/docs/work-items/ORCH-124/01-brd.md b/docs/work-items/ORCH-124/01-brd.md new file mode 100644 index 0000000..e8383b2 --- /dev/null +++ b/docs/work-items/ORCH-124/01-brd.md @@ -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. diff --git a/docs/work-items/ORCH-124/02-trz.md b/docs/work-items/ORCH-124/02-trz.md new file mode 100644 index 0000000..611963b --- /dev/null +++ b/docs/work-items/ORCH-124/02-trz.md @@ -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.` / отдельная таблица 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=` и + `POST /serial-gate/resume?work_item=` (по образцу существующего `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). diff --git a/docs/work-items/ORCH-124/03-acceptance-criteria.md b/docs/work-items/ORCH-124/03-acceptance-criteria.md new file mode 100644 index 0000000..c0c4d44 --- /dev/null +++ b/docs/work-items/ORCH-124/03-acceptance-criteria.md @@ -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 | diff --git a/docs/work-items/ORCH-124/04-test-plan.yaml b/docs/work-items/ORCH-124/04-test-plan.yaml new file mode 100644 index 0000000..62cf9d3 --- /dev/null +++ b/docs/work-items/ORCH-124/04-test-plan.yaml @@ -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 diff --git a/docs/work-items/ORCH-124/06-adr/ADR-001-serial-gate-pause-without-blocking.md b/docs/work-items/ORCH-124/06-adr/ADR-001-serial-gate-pause-without-blocking.md new file mode 100644 index 0000000..4d05d15 --- /dev/null +++ b/docs/work-items/ORCH-124/06-adr/ADR-001-serial-gate-pause-without-blocking.md @@ -0,0 +1,298 @@ +--- +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=`** → `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=`** → `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) diff --git a/docs/work-items/ORCH-124/08-data-requirements.md b/docs/work-items/ORCH-124/08-data-requirements.md new file mode 100644 index 0000000..2dacc4d --- /dev/null +++ b/docs/work-items/ORCH-124/08-data-requirements.md @@ -0,0 +1,53 @@ +--- +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 не требуется и не рекомендуется на прод-БД. diff --git a/docs/work-items/ORCH-124/10-tech-risks.md b/docs/work-items/ORCH-124/10-tech-risks.md new file mode 100644 index 0000000..c3a130c --- /dev/null +++ b/docs/work-items/ORCH-124/10-tech-risks.md @@ -0,0 +1,40 @@ +--- +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-флаг. diff --git a/docs/work-items/ORCH-124/12-review.md b/docs/work-items/ORCH-124/12-review.md new file mode 100644 index 0000000..cc815bc --- /dev/null +++ b/docs/work-items/ORCH-124/12-review.md @@ -0,0 +1,132 @@ +--- +verdict: APPROVED +work_item: ORCH-124 +stage: review +author_agent: reviewer +status: approved +created_at: 2026-06-16 +model_used: claude-opus-4-8 +type: review +work_item_id: ORCH-124 +version: 3 +--- + +# 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 точках определения «активной задачи» +(`build_claim_clause`/`repo_has_active_task`/`_per_repo_snapshot`), операторские эндпоинты +`POST /serial-gate/pause|resume`, под независимым под-флагом `serial_gate_pause_enabled`. + +**Вердикт: APPROVED.** Оба замечания предыдущего ревью (run v2) устранены коммитом `58e5dfe`: +- **P1 (витрина `docs/overview/`)** → исправлено: `tech-pipeline.md` (ось «пауза без блокировки» рядом + с исключением freeze), `tech-data-model.md` (durable-сигнал `tasks.paused_at`), `tech-observability.md` + (`paused`/`reason` в блоке `serial_gate` `GET /queue` + эндпоинты `pause|resume`) — синхронно в этом PR. +- **P2 (хвостовые tool-call-теги)** → исправлено: все 4 golden-source дока этого PR + (`06-adr/ADR-001`, `adr-0051`, `08-data-requirements.md`, `10-tech-risks.md`) очищены (проверено + `tail` + grep по изменённым `.md`; оставшиеся совпадения — лишь описательная проза в `CHANGELOG.md`/ + этом ревью). + +Ядро фикса (код + ТЗ + ADR + тесты) — высокого качества, P0/P1-замечаний по сути нет. Реализация 1:1 +соответствует ADR-001 (D1–D9), закрывает все FR/AC, машинные инварианты не тронуты. Целевой прогон +`tests/test_orch124_serial_gate_pause.py` + `tests/test_orch123_staging_runner_exec.py` + +`tests/test_system_docs.py` зелёный (**70 passed**). + +## Проверка по осям + +### 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 буквально по файлам/тестам; целевой прогон зелёный (70 passed). + +### 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/**` = только `config.py`/`db.py`/`main.py`/`serial_gate.py`; + `stages.py`/`qg/`/`frontmatter.py`/`task_deps.py` вне диффа — подтверждено `git diff --name-only`). ✓ + +### 3. Качество кода — ✅ +- Чисто, читаемо; docstrings на всех новых публичных функциях; маркеры/ссылки на ADR в коде. +- never-raise на всех публичных функциях `serial_gate`/`db`-хелперах; hot-claim fail-OPEN + (pause-терм внутри `try/except` → ошибка `_pause_layer_enabled` даёт `build_claim_clause()==""`), + freeze fail-CLOSED — направления не инвертированы (**TC-13**). ✓ +- Helper-сигнатуры выверены по коду: `db.get_task_by_work_item_id` существует и возвращает `dict` + (`SELECT *` ⇒ `paused_at` попадает в результат), `notifications.link_for`/`send_telegram` существуют + (паттерн `from .notifications import send_telegram, link_for` уже используют `coverage_gate`/ + `staging_runner`). `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, снапшот-reason, анти-дрейф 3 точек, offline hot-path, + never-raise/fail-OPEN, kill-switch-нейтральность, структурный анти-регресс реестров/схем. +- **Тест-гигиена (коммит `3a19728`):** изоляция `settings.repos_dir` в фикстуре + `tests/test_orch123_staging_runner_exec.py` устранила order-dependent FAIL `test_r2…` (фолбэк + `check_staging_status` на реальный `/orchestrator/.../15-staging-log.md` после мержа + ORCH-123). `cfg` импортирован в файле (стр. 39); инвариант ORCH-123 R-2 сохранён; правка только теста; + прозрачно задокументирована в CHANGELOG. Не блокирует. + +### 4. Документация (обязательная ось) — ✅ обновлена полно +`src/` изменён → документация обновлена синхронно в том же PR: +- ✅ `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`, `10-tech-risks.md`. +- ✅ **Витрина системы `docs/overview/` (ORCH-011) — обновлена** (исправление P1 предыдущего ревью): + `tech-pipeline.md` (исключение FIFO «пауза без блокировки»), `tech-data-model.md` (`tasks.paused_at`), + `tech-observability.md` (`paused`/`reason` + эндпоинты). `tests/test_system_docs.py` — зелёный. +- ✅ Root `README.md` «Известные ограничения»: п. про эпик ORCH-088 (пакетный автоном) данным багфиксом + не закрывается → обновления не требует. + +## Findings + +### P0 — Blocker +- Нет. + +### P1 — Must fix +- Нет. (Оба замечания предыдущего ревью — P1 витрина + P2 теги — устранены коммитом `58e5dfe`.) + +### P2 — Should fix +- Нет открытых. (Ранее P2 «хвостовые tool-call-теги в 4 доках» устранён.) + +### 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`). Все классы документации обновлены синхронно и +качественно: инженерные доки (`README.md` архитектуры, `internals.md`, `CHANGELOG.md`, `.env.example`), +оба ADR (work-item `06-adr/ADR-001` + сквозной `adr-0051`), `08-data-requirements.md`, `10-tech-risks.md`, +**и обзорная витрина `docs/overview/`** (`tech-pipeline.md`/`tech-data-model.md`/`tech-observability.md` — +маршрут задач serial-gate с осью «пауза»). Машинные доковые инварианты целы (`test_system_docs.py` +зелёный). Документация = golden source наравне с кодом — требование выполнено. Ось документации **закрыта**, +блокирующих находок нет → `APPROVED`. diff --git a/docs/work-items/ORCH-124/13-test-report.md b/docs/work-items/ORCH-124/13-test-report.md new file mode 100644 index 0000000..53c9e32 --- /dev/null +++ b/docs/work-items/ORCH-124/13-test-report.md @@ -0,0 +1,40 @@ +--- +result: PASS +work_item: ORCH-124 +stage: testing +author_agent: test-runner +status: success +created_at: 2026-06-16 +model_used: n/a +exit_code: 0 +smoke: ok +--- + +# Test Gate Log (deterministic runner, ORCH-116) + +pytest exit-code `0` -> `result: PASS` (smoke: ok). + +Вердикт зафиксирован детерминированным test-раннером (ORCH-116), не LLM. PASS/FAIL = exit-код `pytest` + read-only smoke (`/health`, `/status`, `/queue` + блок `serial_gate`). + +pytest stdout (tail): +``` +.............................................. [ 66%] +........................................................................ [ 69%] +........................................................................ [ 72%] +........................................................................ [ 76%] +........................................................................ [ 79%] +........................................................................ [ 82%] +........................................................................ [ 85%] +........................................................................ [ 89%] +........................................................................ [ 92%] +........................................................................ [ 95%] +........................................................................ [ 99%] +.................. [100%] +=============================== 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 +2178 passed, 1 warning in 97.64s (0:01:37) +``` diff --git a/docs/work-items/ORCH-124/14-deploy-log.md b/docs/work-items/ORCH-124/14-deploy-log.md new file mode 100644 index 0000000..a5045c7 --- /dev/null +++ b/docs/work-items/ORCH-124/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-124 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/docs/work-items/ORCH-124/15-staging-log.md b/docs/work-items/ORCH-124/15-staging-log.md new file mode 100644 index 0000000..cf8a0fc --- /dev/null +++ b/docs/work-items/ORCH-124/15-staging-log.md @@ -0,0 +1,46 @@ +--- +staging_status: SUCCESS +work_item: ORCH-124 +stage: deploy-staging +author_agent: staging-runner +status: success +created_at: 2026-06-16 +model_used: n/a +exit_code: 0 +base_url: http://localhost:8501 +--- + +# Staging Gate Log (deterministic runner, ORCH-115) + +Staging suite exit-code `0` -> `staging_status: SUCCESS`. + +Вердикт зафиксирован детерминированным staging-раннером (ORCH-115), не LLM. infra-tolerance (ORCH-061) уже учтена внутри `staging_check.py` — раннер её не пересуживает. + +INFRA-WAIVED lines (ORCH-061, copied for observability): +- INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green) + +Staging suite stdout (tail): +``` + (waiting for analyst job in queue) + · waiting... (waiting for analyst job in queue) + · waiting... (waiting for analyst job in queue) + · waiting... (waiting for analyst job in queue) + · waiting... (waiting for analyst job in queue) + · waiting... (waiting for analyst job in queue) + ✗ FAIL C9b Analyst job enqueued in staging queue + +[CLEANUP] + · CLEANUP: no branch to delete + ✓ PASS CLEANUP: deleted Plane issue 730cf106-52af-43ad-b8a4-c59fd5f12277 (HTTP 204) + · CLEANUP DB: no task row found for plane_id=730cf106-52af-43ad-b8a4-c59fd5f12277 + · CLEANUP DB dedup: no such table: events_dedup + +============================================================ + RESULT: 8/10 checks PASS + REAL failed : none + SANDBOX_INFRA failed: ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] +============================================================ + · tolerance: staging_infra_tolerance_enabled=True +INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green) +VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green +``` diff --git a/src/config.py b/src/config.py index d379ce1..2d637d4 100644 --- a/src/config.py +++ b/src/config.py @@ -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, diff --git a/src/db.py b/src/db.py index 8bf1c1f..8089400 100644 --- a/src/db.py +++ b/src/db.py @@ -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) # --------------------------------------------------------------------------- diff --git a/src/main.py b/src/main.py index 874c29c..5b0fb11 100644 --- a/src/main.py +++ b/src/main.py @@ -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. diff --git a/src/serial_gate.py b/src/serial_gate.py index 0675e98..b5bc61d 100644 --- a/src/serial_gate.py +++ b/src/serial_gate.py @@ -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, diff --git a/tests/test_orch123_staging_runner_exec.py b/tests/test_orch123_staging_runner_exec.py index 9950184..f803f07 100644 --- a/tests/test_orch123_staging_runner_exec.py +++ b/tests/test_orch123_staging_runner_exec.py @@ -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 / + # (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) diff --git a/tests/test_orch124_serial_gate_pause.py b/tests/test_orch124_serial_gate_pause.py new file mode 100644 index 0000000..31b092f --- /dev/null +++ b/tests/test_orch124_serial_gate_pause.py @@ -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)