Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec932264db | |||
| 3a1972875f | |||
|
|
c7336dd9ea | ||
| 7ac83a9731 | |||
| 87af857082 | |||
| de4f067655 | |||
| fef5ba15d5 | |||
| 569abee5f2 |
@@ -230,9 +230,15 @@ ORCH_TASK_DEPS_SOURCE=db
|
||||
# SERIAL_GATE_ENABLED=false -> claim AND start_pipeline are 1:1 as before ORCH-088.
|
||||
# SERIAL_GATE_REPOS (CSV) -> scope; EMPTY = ALL repos (not self-hosting-only).
|
||||
# SERIAL_GATE_FREEZE_ENABLED=false -> the rollback-freeze layer is off (not set/read).
|
||||
# SERIAL_GATE_PAUSE_ENABLED (ORCH-124) -> per-task "park" axis. true (default) -> a
|
||||
# task with tasks.paused_at NOT NULL (POST /serial-gate/pause?work_item=<id>) is
|
||||
# excluded from the "active task" predicate so an URGENT successor may overtake a
|
||||
# paused predecessor. TRUE no-op until an operator pauses a task. false -> pause-term
|
||||
# omitted, serial-gate byte-for-byte ORCH-088/090. Scope reuses SERIAL_GATE_REPOS.
|
||||
ORCH_SERIAL_GATE_ENABLED=true
|
||||
ORCH_SERIAL_GATE_REPOS=
|
||||
ORCH_SERIAL_GATE_FREEZE_ENABLED=true
|
||||
ORCH_SERIAL_GATE_PAUSE_ENABLED=true
|
||||
# ORCH-090: STOP-status task cancellation (stop active agent + full progress reset)
|
||||
# and the relaunch-hole close. A dedicated Plane "STOP" status (logical key `stop`,
|
||||
# fail-closed: absent from _DEFAULT_STATES, so a board without the status -> no-op)
|
||||
|
||||
@@ -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
|
||||
12
CHANGELOG.md
12
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=<id>` (стамп `paused_at`; терминальная/неизвестная задача → no-op-ответ; под-флаг off → no-op-предупреждение) и `POST /serial-gate/resume?work_item=<id>` (сброс `paused_at` → задача снова участвует в гейте; идемпотентно) — по образцу `POST /serial-gate/unfreeze`, never-raise, с Telegram-подтверждением (`src/main.py`).
|
||||
- **Анти-stale-base при resume (D8, R-1):** новой rebase-машинерии **нет** — свежесть базы гарантируют существующие механизмы: паузнутая-в-`analysis` задача при resume режет ветку отложенно (ORCH-088) от свежего `origin/main` с кодом успешника; материализованная — ребейзится на merge-gate (`auto_rebase_onto_main` под merge-lease ORCH-026/093) + re-test (ORCH-110). Нормальная задача (`paused_at IS NULL`) по-прежнему держит гейт ⇒ анти-stale-base для нормального случая (ORCH-088) **не регрессирует**.
|
||||
- **Наблюдаемость (D5):** блок `serial_gate` в `GET /queue` дополнен ключом `paused` (список приостановленных незавершённых задач репо — НЕ показываются как `active_task`) и `reason` ожидания у каждого waiting-job с приоритетом `freeze` → `dependency` → `active-task` → `null`; существующие ключи снапшота (`active_task`/`waiting`/`frozen`/`frozen_reason`/`frozen_at`) — байт-в-байт (BC).
|
||||
- **Условность/откат (D6):** независимый под-флаг `serial_gate_pause_enabled` (env `ORCH_SERIAL_GATE_PAUSE_ENABLED`, дефолт `True`; зеркало `serial_gate_freeze_enabled`; область переиспользует `serial_gate_repos`, новый `*_repos` не вводится). Дефолт `True` — **истинный no-op** до явной операторской паузы (`paused_at` всюду NULL). `False` ⇒ pause-терм опущен из SQL, эндпоинты no-op, serial-gate **байт-в-байт ORCH-088/090** (осознанный rollback-режим). Глубже — `serial_gate_enabled=false`.
|
||||
- **Покрытие:** `tests/test_orch124_serial_gate_pause.py` (TC-01 обязательный регресс инцидента ORCH-116/ORCH-123 — красный до фикса, зелёный после; TC-02…TC-15: анти-регресс ORCH-088, durable/restart, resume, сохранность freeze/dependency, снапшот-reason, анти-дрейф 3 точек, offline hot-path, never-raise/fail-OPEN, kill-switch-нейтральность, структурный анти-регресс реестров/схем).
|
||||
- **Доки:** обновлены `docs/architecture/README.md` (раздел serial-gate + ось «пауза без блокировки») и `docs/architecture/internals.md` (ось «пауза» ⊥ оси «терминальность»); сквозной ADR `adr-0051`.
|
||||
- **Тест-гигиена (development-стадия, латентный регресс ORCH-123):** изолирован `settings.repos_dir` в фикстуре `tests/test_orch123_staging_runner_exec.py` (зеркально уже имевшейся изоляции `worktrees_dir`). `check_staging_status` при отсутствии фиче-worktree фолбэчит на `<repos_dir>/<repo>` (и его `origin/main`); после мержа ORCH-123 реальный `/repos/orchestrator/docs/work-items/ORCH-123/15-staging-log.md` (вердикт SUCCESS) появился на диске и делал предполагавшийся-КРАСНЫМ staging-гейт в `test_r2_held_deploy_staging_not_rolled_back` зелёным при полном прогоне `pytest tests/` (order-dependent: тест проходил в одиночку, падал в сьюте). Инвариант ORCH-123 R-2 («held `deploy-staging` не откатывается на `development`», adr-0049/ADR-001 D4) **сохранён и усилен** — изоляция лишь восстанавливает заявленную предпосылку теста «15-staging-log.md отсутствует ⇒ гейт красный». `src/**`/`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*` не тронуты (правка только теста).
|
||||
- **Детерминированный test-раннер вместо LLM-тестера на `testing`** (ORCH-116, `feat`): второй реализованный срез determinization-roadmap (ORCH-118 A5, `needs-hybrid-fallback`) — на стадии `testing` для self-hosting `orchestrator` **LLM-агент `tester` заменён детерминированным кодом** (`src/test_runner.py`). PASS/FAIL-ядро агента было деривируемым (регресс `pytest` + read-only smoke → `result:`); каждый прогон жёг токены/время opus-агента (~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 <test_runner_target>` **в worktree ветки** (`git_worktree.get_worktree_path`, анти checkout-гонка ORCH-112) через `proc_group.run_in_process_group` (tree-kill, таймаут `test_runner_timeout_s=900`, малформ/непозитив → дефолт + WARNING) + опц. **read-only smoke** (`/health`/`/status`/`/queue` + блок `serial_gate`, stdlib `urllib`; транзиентная недостижимость — ограниченный ретрай, не-200/нет блока — немедленный FAIL; `test_runner_smoke_enabled`). Маппит exit-код **единым** контрактом `self_deploy.map_exit_code_to_status` в токенах `result:` (`0→PASS`/иначе/None→`FAIL`, fail-closed; smoke-провал AND-ится в `FAIL`); пишет `13-test-report.md` (тот же machine-key `result:` UPPERCASE + 52c-схема, `author_agent: test-runner`/`model_used: n/a`) + best-effort push в **фичеветку**; вызывает **существующий** `advance_stage(current_stage="testing", finished_agent="tester")` — без новых рёбер/исходов (transition-lease ORCH-114 берётся внутри `advance_stage` — граница O1).
|
||||
|
||||
@@ -621,10 +621,34 @@ ORCH-027 вводит детерминированный (без LLM) **гейт
|
||||
`serial_gate_freeze_enabled`. Наблюдаемость — аддитивный блок `serial_gate` в `GET /queue`
|
||||
(per-repo `active_task` / `waiting` / `frozen`). Cross-repo параллелизм сохранён (FR-3); при
|
||||
выключенном флаге — нулевая регрессия (enduro не затронут).
|
||||
- **Ось «пауза без блокировки» (ORCH-124 — [adr-0051](adr/adr-0051-serial-gate-pause-without-blocking.md)).**
|
||||
Баг (инцидент ORCH-116/ORCH-123): serial-gate считал «активной» задачу **исключительно по машинной
|
||||
стадии**, а Plane-статусы Backlog/Blocked/Needs-Input (слой B индикации, ORCH-066) **не меняют
|
||||
`tasks.stage`** ⇒ приостановленный предшественник держал FIFO закрытым против срочного успешника, и у
|
||||
оператора не было чистого механизма «пауза без блокировки», отдельного от cancel (терминал) и от
|
||||
глобального выключения гейта. Решение — **явный per-task park-сигнал**: аддитивная колонка
|
||||
`tasks.paused_at TEXT` (NULL = не на паузе; паттерн `cancelled_at`/`track`) + **новая ортогональная ось
|
||||
планировщика «пауза»**, отделённая от оси «терминальность». serial-gate «активна» ⇔
|
||||
`stage NOT IN ('done','cancelled') AND paused_at IS NULL` (терм `AND t2.paused_at IS NULL` во всех 3
|
||||
точках под под-флагом). **Терминал `{done,cancelled}` в `serial_gate`/`task_deps`/`stages.py` —
|
||||
байт-в-байт (adr-0026 не регрессирует)**: `task_deps`/`stages.py` колонку `paused_at` НЕ читают ⇒
|
||||
паузнутая объявленная зависимость и `repo_freeze` **по-прежнему блокируют** (пауза их не обходит — разные
|
||||
оси). Намерение — явные эндпоинты `POST /serial-gate/pause|resume?work_item=<id>` (по образцу
|
||||
`unfreeze`), durable/offline/webhook-независимо (NFR-2). **Анти-stale-base (ORCH-088) не регрессирует:**
|
||||
нормальная задача (`paused_at IS NULL`) держит гейт; при resume свежесть базы дают существующие механизмы
|
||||
— отложенный срез (для паузнутой-в-`analysis`) и pre-merge `auto_rebase_onto_main` + merge-gate re-test
|
||||
(ORCH-026/093/110) для материализованной ветки; новой rebase-машинерии нет. Наблюдаемость — ключ `paused`
|
||||
+ `reason` ожидания (`active-task`/`dependency`/`freeze`) в блоке `serial_gate` `GET /queue`. Под-флаг
|
||||
`serial_gate_pause_enabled` (env `ORCH_SERIAL_GATE_PAUSE_ENABLED`, дефолт `True`; зеркало
|
||||
`serial_gate_freeze_enabled`); `False` ⇒ pause-терм опущен, serial-gate байт-в-байт ORCH-088/090. Дефолт
|
||||
безопасен (no-op, пока ничего не паузнуто — enduro не затронут). `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/
|
||||
схемы существующих таблиц — не тронуты. Детали —
|
||||
`docs/work-items/ORCH-124/06-adr/ADR-001-serial-gate-pause-without-blocking.md`.
|
||||
|
||||
Подробнее: [adr-0017](adr/adr-0017-serial-gate.md), детально —
|
||||
`docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md`,
|
||||
`docs/work-items/ORCH-088/08-data-requirements.md`.
|
||||
Подробнее: [adr-0017](adr/adr-0017-serial-gate.md) + [adr-0051](adr/adr-0051-serial-gate-pause-without-blocking.md)
|
||||
(пауза), детально — `docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md`,
|
||||
`docs/work-items/ORCH-088/08-data-requirements.md`,
|
||||
`docs/work-items/ORCH-124/06-adr/ADR-001-serial-gate-pause-without-blocking.md`.
|
||||
|
||||
### Авто-режим по лейблам: autoApprove + autoDeploy (ORCH-089 — реализовано)
|
||||
Конвейер имеет два **человеческих** гейта, тормозящих пакетный автономный прогон (эпик
|
||||
@@ -1410,7 +1434,7 @@ Monitoring after Deploy → Done
|
||||
|
||||
## База данных (SQLite)
|
||||
- `events` — входящие вебхуки (дедуп)
|
||||
- `tasks` — задачи и их стадии; колонки `cancelled_at`/`cancel_requested_at` (ORCH-090) — durable-метки STOP-отмены (вторая — отложенная отмена в критичном окне merge/deploy). Терминальная стадия `cancelled` (сток, параллельно `done`); натуральные ключи отменённой строки тумбстонятся суффиксом `#cancelled-<id>` (`plane_id`/`work_item_id`/`plane_issue_id`)
|
||||
- `tasks` — задачи и их стадии; колонки `cancelled_at`/`cancel_requested_at` (ORCH-090) — durable-метки STOP-отмены (вторая — отложенная отмена в критичном окне merge/deploy). Терминальная стадия `cancelled` (сток, параллельно `done`); натуральные ключи отменённой строки тумбстонятся суффиксом `#cancelled-<id>` (`plane_id`/`work_item_id`/`plane_issue_id`). Колонка `paused_at` (ORCH-124, adr-0051) — durable per-task park-сигнал serial-gate (NULL = не на паузе): **ортогональная** оси «терминальность» ось «пауза» (`paused_at IS NOT NULL`), читается **только** serial-gate (`task_deps`/`stages.py` её не читают); паузнутый предшественник не держит FIFO, но не обходит `repo_freeze`/`task_deps`
|
||||
- `agent_runs` — запуски агентов (run_id, usage, cost)
|
||||
- `jobs` — очередь задач (ORCH-1); статусы `queued|running|done|failed|cancelled` (ORCH-090: `cancelled` — терминальный исход STOP, нигде не реквью'ится); колонка `pid` (ORCH-065) — pid агентского процесса для liveness-детекции зомби job-reaper'ом
|
||||
- `job_deps` — декларативные зависимости задач (ORCH-026, Уровень B): `(task_id, depends_on_task_id)`, аддитивная; источник истины планировщика для гейта «B ждёт A»
|
||||
@@ -1429,6 +1453,8 @@ Monitoring after Deploy → Done
|
||||
| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + reaper (ORCH-065) + post_deploy (ORCH-021) + task_deps (ORCH-026) + serial_gate (ORCH-088) + auto_labels (ORCH-089) + stop (ORCH-090) + lessons (ORCH-098) + transition_lease (ORCH-114) + последние jobs |
|
||||
| GET | `/metrics` | ORCH-099 (FND/F1a): read-only машинное «сырьё» для sidecar F1b — конверт `schema_version`/`generated_at`/`clk_tck` + разделы `stages`/`queue`/`agents` (liveness: pid/runtime/cpu_ticks)/`cost`. never-raise по разделам; kill-switch `ORCH_METRICS_ENABLED` (дефолт `True`). Контракт — см. раздел «Сырьё-эндпоинт `/metrics`» |
|
||||
| POST | `/serial-gate/unfreeze` | ORCH-088 (FR-5): ручное снятие per-repo rollback-freeze (query/body `repo=<repo>`) → `{ok, repo, cleared, frozen}`; идемпотентно. Альтернатива — `UPDATE repo_freeze SET cleared_at=datetime('now') WHERE repo=? AND cleared_at IS NULL` |
|
||||
| POST | `/serial-gate/pause` | ORCH-124 (D7): поставить задачу на паузу для serial-gate (query/body `work_item=<id>`) → `{ok, work_item, task_id, paused_at}`; идемпотентно. Паузнутый предшественник не держит FIFO против срочного успешника (пауза ≠ cancel, ≠ глобальный kill-switch); НЕ обходит `repo_freeze`/`task_deps` |
|
||||
| POST | `/serial-gate/resume` | ORCH-124 (D7): снять паузу (query/body `work_item=<id>`) → `{ok, work_item, task_id, paused_at: null}`; идемпотентно. Возобновлённая задача снова участвует в serial-gate; свежесть базы — существующие отложенный срез / merge-gate rebase+re-test |
|
||||
| POST | `/transition-lease/release` | ORCH-114 (FR-6, **опц.**): операторский ручной реклейм застрявшего владения переходом (query/body `work_item=<id>`) → `{ok, task_id, released}`; идемпотентно (паттерн `/serial-gate/unfreeze`). При выключенном `transition_lease_enabled` → no-op |
|
||||
| GET | `/lessons` | ORCH-098 (FR-4): read-only выборка журнала уроков; query-фильтры `type`/`status`/`repo`/`work_item`/`limit` → `{enabled, lessons:[…]}` (всегда `200`, чтение не мутирует). При `lessons_enabled=False` → `{enabled:false, lessons:[]}` |
|
||||
| POST | `/lessons` | ORCH-098 (FR-5): ручная запись урока (JSON-тело, `lesson_type` обязателен, `source="manual"` не дедупится) → `{id}`; при выключенном флаге → `{enabled:false}` |
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
work_item: ORCH-124
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-16
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# ADR-0051: Ось «пауза» serial-gate — park-сигнал без блокировки FIFO
|
||||
|
||||
Сквозной (cross-cutting) ADR. Детальное решение задачи —
|
||||
`docs/work-items/ORCH-124/06-adr/ADR-001-serial-gate-pause-without-blocking.md`.
|
||||
|
||||
Статус: **Proposed** · Дата: 2026-06-16 · Источник: **ORCH-124** (bug → escalate full-cycle)
|
||||
|
||||
## Контекст
|
||||
|
||||
ORCH-088 (serial-gate, adr-0017) определяет «активную задачу репо» **исключительно по машинной стадии**
|
||||
`tasks.stage NOT IN ('done','cancelled')` (после ORCH-090/adr-0026 — с учётом терминала `cancelled`).
|
||||
Plane-статусы Backlog/Blocked/Needs-Input — **слой B (индикация), ORCH-066** — не меняют `tasks.stage`
|
||||
(слой A); у таблицы `tasks` нет колонки статуса. ⇒ приостановленная оператором задача неотличима от
|
||||
активно исполняемой и держит FIFO-гейт (`t2.id < jobs.task_id`) закрытым для более поздних analyst-job
|
||||
того же репо.
|
||||
|
||||
**Инцидент ORCH-116/ORCH-123:** ORCH-116 поставили на паузу, чтобы пропустить срочный фикс ORCH-123, но
|
||||
serial-gate держал analyst-job ORCH-123 в `queued`. Единственные обходы (терминальный `cancel`, довод до
|
||||
`done`, глобальное `serial_gate_enabled=false`) — грубые.
|
||||
|
||||
Горячий путь `serial_gate.build_claim_clause` врезан в `claim_next_job` — **offline SQL** — и сетевого
|
||||
чтения Plane-статуса (как делает reconciler ORCH-060) позволить не может. Нужен **DB-резолвимый** сигнал
|
||||
паузы.
|
||||
|
||||
## Решение
|
||||
|
||||
### Инвариант: «пауза» — ОТДЕЛЬНАЯ ОСЬ планировщика, ортогональная «терминальности»
|
||||
|
||||
Вводится **per-task park-сигнал** — аддитивная нуллабельная колонка **`tasks.paused_at TEXT`**
|
||||
(NULL = не на паузе) — и **новая ось планировщика «пауза»**, независимая от оси «терминальность».
|
||||
|
||||
| Ось | Предикат | Кто использует | Меняется ORCH-124? |
|
||||
|-----|----------|----------------|--------------------|
|
||||
| **Терминальность** (adr-0026) | `stage IN ('done','cancelled')` | `serial_gate` + `task_deps` + `stages.py` | **НЕТ — байт-в-байт** |
|
||||
| **Пауза** (новая, ORCH-124) | `paused_at IS NOT NULL` | **только** FIFO «active» предикат `serial_gate` | да (аддитивно) |
|
||||
|
||||
**serial-gate «активная задача» ⇔ `stage NOT IN ('done','cancelled') AND paused_at IS NULL`.** Это
|
||||
**осознанная, задокументированная** дивергенция serial-gate от чисто-терминального предиката (требование
|
||||
гармонизации adr-0026): пауза выводит предшественника из FIFO-учёта serial-gate, **не делая его
|
||||
терминальным**.
|
||||
|
||||
### Что НЕ меняется (анти-регресс adr-0026)
|
||||
|
||||
- **`task_deps`** (adr-0015) и **`stages.py::STAGE_TRANSITIONS`** колонку `paused_at` **не читают** —
|
||||
остаются чисто терминальными. Явно объявленная зависимость (`job_deps`) на **приостановленную** задачу
|
||||
**по-прежнему блокирует** зависимый job. Пауза («пропустите меня в FIFO») и dependency («B нужен
|
||||
результат A») — разные оси; пауза НЕ обходит dependency и НЕ обходит per-repo `repo_freeze`.
|
||||
- `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict / схемы существующих таблиц — без
|
||||
изменений. Пауза — не стадия и не Quality Gate, а признак планировщика очереди.
|
||||
|
||||
### Точки, признающие ось «пауза» (исчерпывающе)
|
||||
|
||||
1. `src/serial_gate.py::build_claim_clause` — терм `AND t2.paused_at IS NULL` внутри `active_clause`
|
||||
(под под-флагом). **(маркер ORCH-124, рядом с ORCH-088/ORCH-090)**
|
||||
2. `src/serial_gate.py::repo_has_active_task` / `_per_repo_snapshot` — тот же предикат + наблюдаемость
|
||||
(ключ `paused`, `reason` ожидания).
|
||||
3. `src/db.py` — колонка `tasks.paused_at` (`_ensure_column`) + хелперы `set_task_paused`/
|
||||
`clear_task_paused`/`is_task_paused`.
|
||||
4. `src/main.py` — операторские эндпоинты `POST /serial-gate/pause|resume` (по образцу
|
||||
`POST /serial-gate/unfreeze`).
|
||||
|
||||
### Анти-stale-base при возобновлении (ORCH-088 не регрессирует)
|
||||
|
||||
Пауза «демотирует» задачу в FIFO; свежесть базы при resume обеспечивают **существующие** механизмы — новой
|
||||
rebase-машинерии нет: отложенный срез ветки (ORCH-088, для паузнутой-в-`analysis`) + безусловный pre-merge
|
||||
`auto_rebase_onto_main` под merge-lease (ORCH-026/093) + merge-gate re-test (ORCH-110) для уже
|
||||
материализованной ветки. Нормальная задача (`paused_at IS NULL`) по-прежнему держит гейт.
|
||||
|
||||
### Флаги / совместимость
|
||||
|
||||
- Независимый под-флаг `serial_gate_pause_enabled` (env `ORCH_SERIAL_GATE_PAUSE_ENABLED`, дефолт `True`) —
|
||||
зеркало `serial_gate_freeze_enabled`. `False` ⇒ pause-терм опущен из SQL, эндпоинты no-op ⇒ serial-gate
|
||||
байт-в-байт как ORCH-088/090. Область — переиспользует `serial_gate_repos` (новый `*_repos` не вводится).
|
||||
- Дефолт `True` безопасен: пока ни одна задача не на паузе, `paused_at` везде `NULL` ⇒ истинный no-op
|
||||
(enduro не затронут).
|
||||
- never-raise: pause-терм в `build_claim_clause` сохраняет **fail-OPEN**; freeze — **fail-CLOSED**.
|
||||
- Миграция — только аддитивная/идемпотентная (`_ensure_column`); общая прод-БД безопасна (NFR-3).
|
||||
|
||||
## Последствия
|
||||
|
||||
- **+** Чистая операторская «пауза без блокировки», отличная от cancel (терминал) и от kill-switch;
|
||||
durable, offline, webhook-независимая; закрывает инцидент ORCH-116/ORCH-123.
|
||||
- **+** Единый, явно описанный двухосевой предикат планировщика (терминальность ⊥ пауза) — устранён риск
|
||||
будущего рассинхрона.
|
||||
- **−** Появилась вторая ось «активности» serial-gate — будущие подсистемы планировщика обязаны помнить:
|
||||
serial-gate «активна» = `не терминальна И не на паузе`, но **терминал** (`task_deps`/`stages.py`) ось
|
||||
«пауза» НЕ включает. Митигейшн: этот ADR + маркер `ORCH-124` в изменённых местах + тесты.
|
||||
- **Откат:** `ORCH_SERIAL_GATE_PAUSE_ENABLED=false` (serial-gate 1:1 как ORCH-088/090; колонка `paused_at`
|
||||
инертна).
|
||||
|
||||
## Эволюция маркеров
|
||||
|
||||
Горячий SQL serial-gate несёт теперь 3 маркера (`ORCH-088` FIFO-гейт, `ORCH-090` терминал `cancelled`,
|
||||
`ORCH-124` ось паузы) — правка любого из них сверяется с этим сводным ADR (анти-археология: 3+ маркеров →
|
||||
одна ссылка сюда, `docs/_standards/TRACEABILITY.md`).
|
||||
|
||||
## Ссылки
|
||||
- Детальный ADR: `docs/work-items/ORCH-124/06-adr/ADR-001-serial-gate-pause-without-blocking.md`
|
||||
- Данные: `docs/work-items/ORCH-124/08-data-requirements.md`
|
||||
- Связанные: adr-0017 (serial-gate ORCH-088), adr-0026 (терминал `{done,cancelled}` ORCH-090),
|
||||
adr-0015 (task-deps), adr-0027 (merge-актор rebase/retry ORCH-093), adr-0042 (merge-gate re-test ORCH-110)
|
||||
</content>
|
||||
@@ -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 | Метод проверки |
|
||||
|
||||
7
docs/work-items/ORCH-124/00-business-request.md
Normal file
7
docs/work-items/ORCH-124/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: BUG: serial gate treats Backlog/Blocked paused tasks as active and blocks urgent successors
|
||||
|
||||
Work Item ID: ORCH-124
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
185
docs/work-items/ORCH-124/01-brd.md
Normal file
185
docs/work-items/ORCH-124/01-brd.md
Normal file
@@ -0,0 +1,185 @@
|
||||
---
|
||||
work_item: ORCH-124
|
||||
stage: analysis
|
||||
author_agent: analyst
|
||||
status: ready-for-review
|
||||
created_at: 2026-06-16
|
||||
model_used: claude-opus-4-8
|
||||
escalate: full-cycle
|
||||
---
|
||||
|
||||
# 01 — BRD / Bug-report: ORCH-124 — serial gate treats Backlog/Blocked/Needs-Input paused tasks as active and blocks urgent successors
|
||||
|
||||
Work Item: **ORCH-124** · Repo: **orchestrator** · Стадия: analysis · Трек: **Bug → эскалация в full-cycle**
|
||||
|
||||
> ⚠️ **`escalate: full-cycle` (ADR-001 D5 ORCH-019).** Метка задачи — `Bug`, но по сути это
|
||||
> **архитектурный** дефект: требуется **определить семантику wait/terminal состояний serial-gate**
|
||||
> и выбрать механизм «пауза без блокировки» (release-on-status / явный per-task hold-флаг /
|
||||
> переиспользование `task_deps`). Любой вариант пересекается с **корневым инвариантом ORCH-088
|
||||
> (анти-stale-base)** и с гармонизированным терминальным предикатом ORCH-090 (`adr-0026`,
|
||||
> `serial_gate` + `task_deps` + `stages.py`). Это не «однострочная» правка — нужен ADR с явным
|
||||
> разрешением конфликта свойств (см. §8 и `10-tech-risks.md` архитектора). Поэтому выпускается
|
||||
> **полный** analysis-пакет (а не облегчённый bug-пакет). Оператор снимает багфикс-трек:
|
||||
> `POST /bug-fast-track/escalate?work_item=ORCH-124` → задача пойдёт через стадию `architecture`
|
||||
> (architect выпустит ADR для семантики паузы serial-gate).
|
||||
|
||||
---
|
||||
|
||||
## 1. Бизнес-контекст и проблема
|
||||
|
||||
### Симптом (наблюдаемое — установленный факт инцидента)
|
||||
Во время инцидента **ORCH-116/ORCH-123**: задачу **ORCH-116** намеренно поставили на паузу
|
||||
(перевели в Plane-статус Blocked/Backlog), чтобы вперёд пропустить срочный фикс **ORCH-123**.
|
||||
Однако `serial_gate` **по-прежнему считал ORCH-116 активной задачей** (`active_task`) и держал
|
||||
analyst-job ORCH-123 в очереди (`queued`) — срочный фикс не мог стартовать, пока ORCH-116
|
||||
формально не `done`/`cancelled`.
|
||||
|
||||
### Причина симптома (установленный факт — верифицировано по коду)
|
||||
`serial_gate` определяет «активную задачу репо» **исключительно по машинной стадии**
|
||||
`tasks.stage NOT IN ('done','cancelled')` — в трёх местах `src/serial_gate.py`:
|
||||
- `build_claim_clause()` — горячий SQL-фрагмент в `db.claim_next_job`:
|
||||
`EXISTS (SELECT 1 FROM tasks t2 WHERE t2.repo = jobs.repo AND t2.id < jobs.task_id AND
|
||||
t2.stage NOT IN ('done','cancelled'))`;
|
||||
- `repo_has_active_task()` — Python-зеркало для наблюдаемости;
|
||||
- `_per_repo_snapshot()` — выбор `active_task` для блока `serial_gate` в `GET /queue`.
|
||||
|
||||
При этом **Plane-статусы Backlog / Blocked / Needs Input — это слой B (индикация), ORCH-066**, и они
|
||||
**не меняют `tasks.stage` (слой A)**. Сеттеры `set_issue_blocked` / `set_issue_needs_input`
|
||||
(`src/plane_sync.py`) делают только `PATCH` Plane-статуса; машинная стадия задачи остаётся прежней
|
||||
(`analysis` / `development` / `deploy-staging` …). Подтверждение из кода: у таблицы `tasks` **нет
|
||||
колонки статуса** (комментарий `src/reconciler.py:322`: «`tasks` has no status column, so the live
|
||||
Plane state is the source of truth»). Следовательно для serial-gate приостановленная задача неотличима
|
||||
от активно исполняемой — её стадия не входит в `{done,cancelled}`, значит она «активна» и блокирует
|
||||
FIFO всех более поздних analyst-job того же репо.
|
||||
|
||||
### Почему это важно (бизнес-боль)
|
||||
- **Срочный фикс не запускается**, пока более ранняя задача поставлена на паузу. Единственные
|
||||
существующие способы «разблокировать» — терминально `cancel`/довести до `done`, либо целиком
|
||||
выключить serial-gate (`ORCH_SERIAL_GATE_ENABLED=false`) для всех репо. Все три — грубые.
|
||||
- У оператора **нет чистого механизма «пауза без блокировки»** с явным намерением — отдельного от
|
||||
отмены (терминал) и от глобального выключения гейта.
|
||||
- На пакетном автономном прогоне (эпик ORCH-088) это превращает любую «отложенную» задачу в стоп-кран
|
||||
очереди репо.
|
||||
|
||||
### Прецедент в коде (контекст для архитектора, не решение)
|
||||
Reconciler уже **умеет** уважать wait-состояния: ORCH-060 Guard 2 (`reconciler._is_blocked_or_needs_input`,
|
||||
Variant A) **сетевым** запросом Plane-статуса пропускает Blocked/Needs-Input (и активные
|
||||
ORCH-066-ожидания) и не «оживляет» их. Но reconciler — фоновый тик и **может** позволить себе сетевой
|
||||
вызов; `serial_gate.build_claim_clause` врезан в `claim_next_job` (**offline hot-path**) и сетевого
|
||||
вызова позволить **не может** (NFR-2 ниже). Это центральное расхождение, которое и порождает баг:
|
||||
сигнал паузы есть в Plane, но не доступен горячему SQL гейта.
|
||||
|
||||
## 2. Объём (scope)
|
||||
|
||||
### В объёме
|
||||
- Определить **семантику wait/terminal** для serial-gate: какие состояния задачи-предшественника
|
||||
НЕ должны держать FIFO-гейт закрытым для более поздней задачи.
|
||||
- Дать оператору **явный, durable, DB-резолвимый** механизм «пауза без блокировки» (или формально
|
||||
переиспользовать существующий: freeze / task_deps), с чётким намерением, отличным от cancel.
|
||||
- Поправить определение «активной задачи» во **всех трёх** точках `serial_gate.py`, чтобы
|
||||
приостановленная задача не считалась `active_task`.
|
||||
- Корректная **причина ожидания** в блоке `serial_gate` снапшота `GET /queue`
|
||||
(active task / paused-predecessor / freeze / dependency).
|
||||
- Тесты: предшественник Blocked/Backlog/Needs-Input + срочный успешник; регресс-тест инцидента.
|
||||
|
||||
### Вне объёма
|
||||
- Изменения `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключей — **не трогаем**
|
||||
(маршрутизация очереди — свойство планировщика, не Quality Gate).
|
||||
- Введение нового **машинного** статуса в `STAGE_TRANSITIONS` (это не новая стадия конвейера).
|
||||
- Изменение поведения reconciler ORCH-060 (его networked-skip уже корректен; гармонизация — на
|
||||
усмотрение архитектора, но переписывать его не требуется).
|
||||
- Автоматическое управление паузой по данным вне явного намерения оператора (никакого эвристического
|
||||
«само-распаузивания»).
|
||||
- Конкретный **выбор механизма** (release-on-status vs per-task hold-флаг vs task_deps) — это решение
|
||||
**архитектора** (ADR), а не аналитика.
|
||||
|
||||
## 3. Заинтересованные стороны
|
||||
- **Оператор/владелец конвейера (Слава)** — заказчик: нуждается в чистой паузе, чтобы пропускать
|
||||
срочные фиксы без отмены и без выключения гейта.
|
||||
- **Self-hosting orchestrator** — затрагивается напрямую (serial-gate активен для всех репо).
|
||||
- **enduro-trails** — затрагивается косвенно (общая БД/очередь); регрессия недопустима при
|
||||
выключенном/нейтральном поведении.
|
||||
- **Архитектор** — принимает решение о механизме и семантике (ADR), разрешает конфликт §8.
|
||||
- Принимает результат — reviewer + tester по критериям `03-acceptance-criteria.md`.
|
||||
|
||||
## 4. Бизнес-требования (BR)
|
||||
- **BR-1** — Перевод задачи-предшественника в состояние паузы/ожидания (Backlog / Blocked /
|
||||
Needs Input) **больше не должен случайно блокировать** более позднюю срочную задачу того же репо в
|
||||
serial-gate. Проверяемо: analyst-job успешника становится claimable.
|
||||
- **BR-2** — У оператора есть **чистый механизм «пауза без блокировки»** с явным намерением,
|
||||
**отличный** от `cancel` (терминал) и от глобального выключения гейта. Намерение — durable
|
||||
(переживает рестарт процесса/контейнера).
|
||||
- **BR-3** — Пауза снимает гейт **только по явному намерению**. **Нормально исполняемая** задача
|
||||
(реально идёт работа) **по-прежнему держит** гейт — анти-stale-base гарантия ORCH-088 не
|
||||
регрессирует (см. §8 — конфликт свойств, разрешает архитектор).
|
||||
- **BR-4** — Снапшот `serial_gate` в `GET /queue` показывает **корректную причину** ожидания
|
||||
успешника: активная задача / приостановленный предшественник / freeze / dependency.
|
||||
- **BR-5** — При **возобновлении** (распаузе) задачи serial-ordering корректно восстанавливается:
|
||||
возобновлённая задача снова участвует в гейте (либо держит его, либо явно ре-входит в FIFO с
|
||||
обязательством rebase) — нет «вечного обхода» и нет потерянного намерения.
|
||||
- **BR-6** — Существующие гарантии serial-gate сохранены: FIFO по более ранним незавершённым
|
||||
задачам, durable per-repo `freeze` (`repo_freeze`), cross-repo параллелизм, явные `task_deps` —
|
||||
по-прежнему блокируют, где должны.
|
||||
|
||||
## 5. Нефункциональные требования (NFR)
|
||||
- **NFR-1 (never-raise / fail-safe)** — Контракт leaf `serial_gate` сохранён: каждая публичная
|
||||
функция деградирует консервативно. Сохранить два направления отказа ORCH-088: hot-claim build →
|
||||
**fail-OPEN** (`""`-фрагмент, не заклинить очередь всех проектов, AC-8 ORCH-088); freeze-решение →
|
||||
**fail-CLOSED** (прод-безопасность, AC-9 ORCH-088). Новая логика паузы не должна инвертировать эти
|
||||
направления.
|
||||
- **NFR-2 (чистота hot-path)** — Гейт-в-claim остаётся **offline SQL-предикатом**; **никаких сетевых
|
||||
вызовов** (в т.ч. Plane API) в `claim_next_job`. Сигнал «пауза» обязан быть **DB-резолвимым**
|
||||
(durable колонка/таблица), а не считываться из Plane на горячем пути (в отличие от reconciler).
|
||||
- **NFR-3 (обратная совместимость / kill-switch / область)** — Изменение аддитивно и обратимо;
|
||||
выключатель (существующий `serial_gate_enabled` либо новый под-флаг) → байт-в-байт прежнее поведение
|
||||
до ORCH-124. `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи / **схемы
|
||||
существующих таблиц** — без изменений (новая колонка/таблица — только аддитивно, паттерн
|
||||
`_ensure_column` / `CREATE TABLE IF NOT EXISTS`). enduro не затронут при нейтральном поведении.
|
||||
- **NFR-4 (гармонизация предиката)** — Любой новый предикат «активна/терминальна/пауза» должен
|
||||
оставаться согласованным с гармонизированным терминальным множеством `{done,cancelled}` в
|
||||
`serial_gate` + `task_deps` + `stages.py` (ORCH-090 / adr-0026), либо архитектор явно описывает,
|
||||
почему serial-gate расходится (паузой), не ломая `task_deps`.
|
||||
- **NFR-5 (self-hosting безопасность)** — Никакого рестарта/падения прод-контейнера, мутации `main`,
|
||||
force-push; только чтение/запись своих таблиц и принятие решения о claim. Hot-path не должен
|
||||
замедляться сетью или тяжёлым запросом.
|
||||
|
||||
## 6. Допущения и ограничения
|
||||
- У таблицы `tasks` **сегодня нет колонки статуса**; Plane-статус (Backlog/Blocked/Needs Input) —
|
||||
слой B индикации, в БД не отражён. Значит «пауза» для горячего пути требует **нового durable
|
||||
DB-сигнала** (колонка `tasks` или отдельная таблица), либо переиспользования уже DB-резолвимого
|
||||
механизма (`repo_freeze` / `task_deps`).
|
||||
- `repo_freeze` существует, но **freeze'ит весь репо** (блокирует всех успешников) — это
|
||||
противоположность «пропустить срочного успешника», поэтому как есть не годится для BR-1 (но годится
|
||||
как явный блок для BR-6).
|
||||
- `task_deps` (`job_deps`) — явные декларации зависимостей, уже DB-резолвимы и консультируются в
|
||||
`claim_next_job` (`NOT EXISTS`); кандидат на «explicit intent», на усмотрение архитектора.
|
||||
- Reconciler ORCH-060 различает Blocked/Needs-Input **сетевым** запросом Plane — прецедент семантики,
|
||||
но **не переиспользуем** на hot-path (NFR-2).
|
||||
- Серый кейс: Needs Input во время `analysis` — нормальное короткое ожидание ответа; решение, считать
|
||||
ли его «паузой для гейта», за архитектором (важно не превратить штатное короткое ожидание в обход
|
||||
анти-stale-base).
|
||||
|
||||
## 7. Критерии успеха
|
||||
Кратко (детальные PASS/FAIL — `03-acceptance-criteria.md`):
|
||||
- Приостановленный предшественник (Backlog/Blocked/Needs-Input по явному намерению) не блокирует
|
||||
срочного успешника; нормально идущая задача — блокирует; freeze/dependency блокируют, где должны;
|
||||
`GET /queue` показывает корректную причину; всё под kill-switch; машинные инварианты байт-в-байт;
|
||||
регресс-тест инцидента красный до фикса и зелёный после.
|
||||
|
||||
## 8. Риски
|
||||
Кратко (детально — `10-tech-risks.md`, заполняет архитектор):
|
||||
- **R-1 (ключевой конфликт свойств) — пауза vs анти-stale-base (ORCH-088).** Если «пауза» освобождает
|
||||
гейт, успешник срежет ветку от `main`, ещё **не** содержащего код предшественника. Когда
|
||||
приостановленный предшественник позже возобновится и смержится — его база/ветка могут стать stale.
|
||||
ORCH-088 был построен ровно чтобы это предотвратить. Архитектор обязан разрешить конфликт явно
|
||||
(напр.: пауза «демотирует» задачу в FIFO и обязывает rebase при возобновлении; либо явный per-task
|
||||
«yield» с принятием rebase). **Аналитик фиксирует конфликт, не выбирает решение.**
|
||||
- **R-2** — Случайное/неявное освобождение гейта (баг в детекте намерения) ослабит сериализацию для
|
||||
всех — требуется строго **явное** намерение оператора.
|
||||
- **R-3** — Рассинхрон «Plane-статус ↔ DB-сигнал паузы»: если механизм опирается на webhook о смене
|
||||
статуса, потерянный webhook оставит задачу «активной» в БД (или наоборот). Нужен durable,
|
||||
идемпотентный, восстановимый сигнал.
|
||||
- **R-4** — Регрессия гармонизированного предиката `{done,cancelled}` в `task_deps`/`stages.py`, если
|
||||
serial-gate изменит понимание «активности» неаккуратно.
|
||||
- **R-5** — fail-direction: ошибка в новой ветке не должна инвертировать fail-OPEN (claim) /
|
||||
fail-CLOSED (freeze) контракты ORCH-088.
|
||||
133
docs/work-items/ORCH-124/02-trz.md
Normal file
133
docs/work-items/ORCH-124/02-trz.md
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
work_item: ORCH-124
|
||||
stage: analysis
|
||||
author_agent: analyst
|
||||
status: ready-for-review
|
||||
created_at: 2026-06-16
|
||||
model_used: claude-opus-4-8
|
||||
escalate: full-cycle
|
||||
---
|
||||
|
||||
# 02 — ТЗ (TRZ): ORCH-124 — wait/terminal-семантика serial-gate (пауза без блокировки)
|
||||
|
||||
Work Item: **ORCH-124** · Repo: **orchestrator** · Стадия: analysis
|
||||
|
||||
> ТЗ описывает **требования к изменениям**, выведенные из BRD и фактического кода. **Выбор
|
||||
> механизма** «паузы без блокировки» (release-on-status / per-task hold-флаг / task_deps) и его
|
||||
> архитектурное обоснование — задача **архитектора** (`06-adr`, эскалация в full-cycle). Ниже —
|
||||
> что должно стать истинным и какие модули это затронет, без предписания «как именно».
|
||||
|
||||
## 1. Сводка изменения
|
||||
Сейчас serial-gate (`src/serial_gate.py`) считает «активной» любую задачу репо со стадией вне
|
||||
`{done,cancelled}` — в трёх точках (`build_claim_clause` / `repo_has_active_task` /
|
||||
`_per_repo_snapshot`). Поскольку Plane-статусы Backlog/Blocked/Needs-Input (слой B индикации) **не
|
||||
меняют `tasks.stage`** (слой A), приостановленная задача неотличима от активной и держит FIFO-гейт
|
||||
закрытым для более поздних analyst-job. Требуется ввести **явный, durable, DB-резолвимый** признак
|
||||
«пауза/park» и научить определение «активной задачи» его учитывать, **сохранив** анти-stale-base
|
||||
ORCH-088 при возобновлении (R-1). Машинные гейты не трогаются — это правка **планировщика очереди**.
|
||||
|
||||
## 2. Задействованные модули / пути
|
||||
| Путь | Действие |
|
||||
|------|----------|
|
||||
| `src/serial_gate.py` | изменить — определение «активной задачи» во всех 3 точках (`build_claim_clause`, `repo_has_active_task`, `_per_repo_snapshot`); причина ожидания в снапшоте |
|
||||
| `src/db.py` | изменить (вероятно) — `claim_next_job` (учёт нового предиката в горячем SQL) + (если выбран DB-сигнал) аддитивная колонка/таблица `_ensure_column`/`CREATE TABLE IF NOT EXISTS` + read-only/мутатор-хелперы |
|
||||
| `src/config.py` | изменить — под-флаг(и) семантики паузы (kill-switch), область репо (по образцу `serial_gate_*`) |
|
||||
| `src/main.py` | изменить (вероятно) — операторский эндпоинт pause/resume **или** расширение блока `serial_gate` в `GET /queue` причиной ожидания |
|
||||
| `src/webhooks/plane.py` и/или `src/plane_sync.py` | изменить (если механизм = release-on-status) — захват намерения паузы из смены Plane-статуса в durable DB-сигнал (не на hot-path) |
|
||||
| `tests/test_serial_gate*.py` (новый `tests/test_orch124_serial_gate_pause.py`) | создать/дополнить — кейсы паузы + регресс инцидента |
|
||||
| `docs/architecture/README.md`, `CHANGELOG.md`, `docs/work-items/ORCH-124/06-adr/` | обновить — раздел serial-gate + ADR (архитектор) |
|
||||
|
||||
> Точный набор модулей зависит от выбранного механизма (ADR). Минимально-необходимый набор —
|
||||
> `serial_gate.py` (3 точки) + `db.py` (hot-path) + `config.py` (флаг) + тесты; остальное — по решению
|
||||
> архитектора.
|
||||
|
||||
## 3. Функциональные требования
|
||||
|
||||
### FR-1 — Признак паузы исключает задачу из «активных» в горячем SQL (BR-1, NFR-2)
|
||||
`build_claim_clause()`: подзапрос `EXISTS (... t2.stage NOT IN ('done','cancelled'))` должен
|
||||
**дополнительно** исключать приостановленные задачи-предшественники. Предикат — **чисто SQL по
|
||||
локальной БД** (никакой сети). Форма исключения — функция выбранного DB-сигнала (доп. колонка
|
||||
`tasks.<paused>` / отдельная таблица hold / `task_deps`): архитектор фиксирует точную SQL-форму в ADR.
|
||||
Инвариант: job'ы уже активной задачи (`agent != 'analyst'`) проходят как раньше; самоблокировки
|
||||
собственной строки (R-7 ORCH-088) нет.
|
||||
|
||||
### FR-2 — Зеркало и снапшот согласованы с FR-1 (BR-1/BR-4)
|
||||
`repo_has_active_task()` и `_per_repo_snapshot()` используют **тот же** предикат «активна», что и
|
||||
`build_claim_clause` — приостановленный предшественник не попадает в `active_task`. Все три точки
|
||||
правятся согласованно (анти-дрейф: они должны давать один ответ на один вход).
|
||||
|
||||
### FR-3 — Явное durable намерение паузы (BR-2, BR-5, NFR-2)
|
||||
Должен существовать **явный** оператор-инициируемый сигнал «park/pause» задачи, **durable**
|
||||
(переживает рестарт) и **DB-резолвимый**. Распауза (resume) — обратная операция, восстанавливающая
|
||||
участие задачи в serial-gate (BR-5). Сигнал **отличен** от `cancel`/`done` (не терминальный) и от
|
||||
глобального kill-switch. Конкретная форма (новый эндпоинт `POST /serial-gate/pause|resume`, или
|
||||
маппинг Plane-статуса в DB-сигнал через webhook, или декларация `task_deps`) — **решение архитектора**.
|
||||
|
||||
### FR-4 — Анти-stale-base при возобновлении (BR-3, R-1 — критично)
|
||||
Решение обязано **не регрессировать** ORCH-088: нормально исполняемая задача держит гейт; а
|
||||
возобновлённая ранее приостановленная задача не должна приводить к stale-базе у успешника, который
|
||||
прошёл вперёд. Механизм разрешения (демотирование в FIFO + обязательный rebase при resume / явный
|
||||
yield с принятием rebase / иное) фиксируется архитектором в ADR. ТЗ требует лишь: после распаузы
|
||||
ни одна из задач не остаётся на устаревшей базе незаметно.
|
||||
|
||||
### FR-5 — Корректная причина ожидания (BR-4)
|
||||
Блок `serial_gate` в `GET /queue` для ожидающего успешника различает причины: `active-task`
|
||||
(идёт работа) / `paused-predecessor` (предшественник на паузе — не должно случаться после фикса, но
|
||||
наблюдаемо) / `freeze` / `dependency`. Аддитивно к существующим ключам снапшота (`active_task` /
|
||||
`waiting` / `frozen` / `frozen_reason` / `frozen_at`).
|
||||
|
||||
### FR-6 — Явные блоки сохранены (BR-6)
|
||||
`repo_freeze` (durable per-repo freeze) и `task_deps` (`job_deps`) продолжают блокировать claim ровно
|
||||
как сейчас. «Пауза» НЕ должна обходить freeze/dependency — это разные оси (freeze = весь репо;
|
||||
dependency = конкретная пара; пауза = «пропустите меня»).
|
||||
|
||||
### FR-7 — Условность и нейтральность (NFR-3)
|
||||
Поведение под выключателем (существующий `serial_gate_enabled` либо новый под-флаг паузы) → байт-в-байт
|
||||
до ORCH-124. Область репо — по образцу `serial_gate_repos` (CSV; пусто → текущая область serial-gate,
|
||||
т.е. все репо). enduro не затронут при нейтральном поведении.
|
||||
|
||||
## 4. Изменения API
|
||||
Вероятно (на усмотрение архитектора, ADR):
|
||||
- **Новые операторские эндпоинты** `POST /serial-gate/pause?work_item=<id>` и
|
||||
`POST /serial-gate/resume?work_item=<id>` (по образцу существующего `POST /serial-gate/unfreeze`),
|
||||
если механизм = явный per-task hold. Возвращают новое состояние (paused/active) задачи.
|
||||
- **Расширение** `GET /queue` → блок `serial_gate` дополняется причиной ожидания (FR-5) и (если есть)
|
||||
списком приостановленных задач репо. Существующие ключи не меняются (BC).
|
||||
|
||||
Если механизм = release-on-status, новых ручных эндпоинтов может не быть (намерение — смена Plane-статуса,
|
||||
захватываемая webhook'ом в durable DB-сигнал); тогда раздел сводится к расширению `GET /queue`.
|
||||
|
||||
## 5. Изменения схемы БД
|
||||
Вероятно **аддитивно** (на усмотрение архитектора):
|
||||
- Колонка `tasks.paused_at TEXT` (NULL = не на паузе), идемпотентно через `_ensure_column` — паттерн
|
||||
существующих `tasks.cancelled_at` / `tasks.cancel_requested_at` / `tasks.track`; **или**
|
||||
- Отдельная таблица hold (по образцу `repo_freeze`: `work_item_id`/`task_id`, `paused_at`,
|
||||
`cleared_at IS NULL` = активна), `CREATE TABLE IF NOT EXISTS`.
|
||||
Схемы **существующих** таблиц (`tasks`/`jobs`/`job_deps`/`repo_freeze`) — без деструктивных изменений.
|
||||
Если механизм = task_deps, новой схемы может не понадобиться вовсе. Финальное решение — ADR +
|
||||
`08-data-requirements.md` (архитектор).
|
||||
|
||||
## 6. Требования к новым/изменённым QG checks
|
||||
**Нет.** `STAGE_TRANSITIONS` / состав `QG_CHECKS` / семантика и имена `check_*` / machine-verdict ключи
|
||||
(`verdict:`/`result:`/`deploy_status:`/`staging_status:`/`security_status:`) — **байт-в-байт не
|
||||
трогаются**. ORCH-124 — правка планировщика очереди (claim) и наблюдаемости, **не** Quality Gate и
|
||||
**не** новая стадия конвейера.
|
||||
|
||||
## 7. Совместимость / регресс
|
||||
- **Обратная совместимость:** аддитивно; выключатель → поведение до ORCH-124 байт-в-байт (NFR-3,
|
||||
FR-7). Существующие тесты serial-gate (`tests/test_serial_gate*.py`) остаются зелёными.
|
||||
- **Kill-switch / область:** переиспользовать `serial_gate_enabled` (kill-switch) + при необходимости
|
||||
ввести под-флаг семантики паузы (независимый тумблер, по образцу `serial_gate_freeze_enabled`) и
|
||||
область `*_repos` (CSV).
|
||||
- **Обратимость:** выставить под-флаг паузы в `False` → serial-gate работает как ORCH-088/090
|
||||
(приостановленные снова считаются активными — т.е. возврат к текущему багу, но это осознанный
|
||||
rollback-режим, не дефолт).
|
||||
- **never-raise / fail-direction (NFR-1):** сохранить fail-OPEN на hot-claim build и fail-CLOSED на
|
||||
freeze; новая ветка паузы не инвертирует эти направления.
|
||||
- **Гармонизация предиката (NFR-4):** не сломать `task_deps`/`stages.py` терминальное множество
|
||||
`{done,cancelled}` (ORCH-090/adr-0026); расхождение serial-gate (паузой) — только осознанно и
|
||||
задокументированно в ADR.
|
||||
- **Артефакты pipeline:** обновляются `docs/architecture/README.md` (раздел serial-gate ORCH-088 +
|
||||
семантика паузы), `CHANGELOG.md`, новый `docs/work-items/ORCH-124/06-adr/ADR-001-*.md` (архитектор),
|
||||
при необходимости `08-data-requirements.md` (архитектор). Reviewer проверяет обновление доки (правило
|
||||
агентов §6).
|
||||
136
docs/work-items/ORCH-124/03-acceptance-criteria.md
Normal file
136
docs/work-items/ORCH-124/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,136 @@
|
||||
---
|
||||
work_item: ORCH-124
|
||||
stage: analysis
|
||||
author_agent: analyst
|
||||
status: ready-for-review
|
||||
created_at: 2026-06-16
|
||||
model_used: claude-opus-4-8
|
||||
escalate: full-cycle
|
||||
---
|
||||
|
||||
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-124 — wait/terminal-семантика serial-gate
|
||||
|
||||
Work Item: **ORCH-124** · Repo: **orchestrator** · Стадия: analysis
|
||||
|
||||
Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** (что считается
|
||||
провалом). Reviewer/tester проверяет их буквально по файлам репозитория и тестам.
|
||||
|
||||
---
|
||||
|
||||
## AC-1 — Приостановленный предшественник не блокирует срочного успешника (регресс инцидента)
|
||||
|
||||
**Условие:** репо имеет более раннюю задачу A (`A.id < B.id`) в состоянии паузы (Blocked/Backlog/
|
||||
Needs-Input по явному намерению оператора) и более позднюю срочную задачу B с queued analyst-job.
|
||||
- **PASS:** `build_claim_clause()` НЕ блокирует analyst-job задачи B; `claim_next_job()` выбирает его;
|
||||
B входит в `analysis`. Тест воспроизводит инцидент ORCH-116/ORCH-123 (красный до фикса, зелёный
|
||||
после).
|
||||
- **FAIL:** analyst-job B остаётся `queued`, потому что приостановленная A всё ещё считается активной.
|
||||
|
||||
---
|
||||
|
||||
## AC-2 — Нормально исполняемая задача по-прежнему держит гейт (анти-stale-base, без регресса ORCH-088)
|
||||
|
||||
**Условие:** репо имеет более раннюю задачу A, **реально исполняемую** (не на паузе, стадия вне
|
||||
`{done,cancelled}`) и более позднюю задачу B с queued analyst-job.
|
||||
- **PASS:** analyst-job B **остаётся заблокированным** (FIFO ORCH-088 цел); B стартует только когда A
|
||||
становится `done`/`cancelled` (или явно поставлена на паузу оператором).
|
||||
- **FAIL:** B стартует поверх ещё не завершённой активной A → возврат stale-base дефекта, который
|
||||
закрывал ORCH-088.
|
||||
|
||||
---
|
||||
|
||||
## AC-3 — Пауза снимает гейт только по явному durable намерению
|
||||
|
||||
**Условие:** способ перевода задачи в «паузу» для serial-gate.
|
||||
- **PASS:** освобождение гейта происходит **только** при явном операторском сигнале (эндпоинт
|
||||
pause / выбранный механизм ADR), сигнал **durable** (переживает рестарт процесса/контейнера) и
|
||||
**DB-резолвимый**. Никакого эвристического само-распаузивания.
|
||||
- **FAIL:** гейт снимается без явного намерения (например, по умолчанию для любой не-`done` задачи) ИЛИ
|
||||
намерение теряется после рестарта.
|
||||
|
||||
---
|
||||
|
||||
## AC-4 — Анти-stale-base при возобновлении (R-1 разрешён архитектором)
|
||||
|
||||
**Условие:** приостановленная A возобновлена после того, как успешник B уже прошёл вперёд.
|
||||
- **PASS:** ни A, ни B не остаются незаметно на устаревшей базе — выбранный механизм (демотирование A
|
||||
в FIFO + обязательный rebase / явный yield с rebase / иное по ADR) гарантирует, что возобновлённая
|
||||
задача строится/мержится на актуальном `origin/main`. Поведение зафиксировано тестом по контракту ADR.
|
||||
- **FAIL:** возобновлённая A или прошедший B приводит к stale-ветке/затиранию кода без сигнала.
|
||||
|
||||
---
|
||||
|
||||
## AC-5 — Явные блоки (freeze + dependency) сохранены
|
||||
|
||||
**Условие:** активный `repo_freeze` для репо ИЛИ объявленная незавершённая зависимость в `job_deps`.
|
||||
- **PASS:** claim успешника по-прежнему **заблокирован** freeze'ем (до ручного
|
||||
`POST /serial-gate/unfreeze`) и/или незавершённой зависимостью (`task_deps`) — пауза их не обходит.
|
||||
- **FAIL:** «пауза» позволяет обойти freeze или объявленную зависимость.
|
||||
|
||||
---
|
||||
|
||||
## AC-6 — Корректная причина ожидания в `GET /queue`
|
||||
|
||||
**Условие:** блок `serial_gate` в снапшоте `GET /queue`.
|
||||
- **PASS:** для ожидающего успешника видна корректная причина ожидания (`active-task` /
|
||||
`paused-predecessor` / `freeze` / `dependency`); приостановленный предшественник НЕ показывается как
|
||||
`active_task`. Существующие ключи снапшота не сломаны (BC).
|
||||
- **FAIL:** снапшот показывает приостановленную задачу как `active_task` ИЛИ причина ожидания неверна/
|
||||
отсутствует.
|
||||
|
||||
---
|
||||
|
||||
## AC-7 — Hot-path остаётся offline (без сети)
|
||||
|
||||
**Условие:** путь `db.claim_next_job` → `serial_gate.build_claim_clause`.
|
||||
- **PASS:** определение «активна/пауза» резолвится **только** локальной БД (SQL); ни одного сетевого
|
||||
вызова (Plane API и т.п.) на горячем пути claim. Проверяемо тестом (claim работает без сети / без
|
||||
Plane).
|
||||
- **FAIL:** claim делает сетевой вызов для определения паузы.
|
||||
|
||||
---
|
||||
|
||||
## AC-8 — Машинные инварианты байт-в-байт; kill-switch off → прежнее поведение
|
||||
|
||||
**Условие:** реестры и выключатель.
|
||||
- **PASS:** `STAGE_TRANSITIONS` / состав `QG_CHECKS` / имена и семантика `check_*` / machine-verdict
|
||||
ключи / схемы существующих таблиц — без изменений (структурный тест). При выключенном под-флаге паузы
|
||||
serial-gate ведёт себя байт-в-байт как ORCH-088/090.
|
||||
- **FAIL:** изменён любой machine-verdict ключ / состав `QG_CHECKS` / `STAGE_TRANSITIONS`, либо
|
||||
выключенный флаг меняет поведение.
|
||||
|
||||
---
|
||||
|
||||
## AC-9 — never-raise и сохранённые fail-directions
|
||||
|
||||
**Условие:** ошибки внутри новой логики паузы.
|
||||
- **PASS:** все публичные функции `serial_gate` never-raise; hot-claim build по-прежнему **fail-OPEN**
|
||||
(`""`-фрагмент при ошибке), freeze-решение по-прежнему **fail-CLOSED**; новая ветка паузы не
|
||||
инвертирует эти направления. Проверяемо тестом (инъекция ошибки → claim не падает, очередь не
|
||||
заклинивает).
|
||||
- **FAIL:** ошибка в ветке паузы роняет claim/worker ИЛИ инвертирует fail-direction.
|
||||
|
||||
---
|
||||
|
||||
## AC-10 — Возобновление восстанавливает участие в гейте (BR-5)
|
||||
|
||||
**Условие:** распауза ранее приостановленной задачи.
|
||||
- **PASS:** после resume задача снова участвует в serial-gate согласно выбранной семантике (держит
|
||||
гейт как активная либо ре-входит в FIFO); нет «вечного обхода».
|
||||
- **FAIL:** возобновлённая задача навсегда остаётся вне гейта / её намерение паузы «залипает».
|
||||
|
||||
---
|
||||
|
||||
## Сводная матрица AC ↔ FR/BR
|
||||
| AC | Покрывает |
|
||||
|----|-----------|
|
||||
| AC-1 | BR-1 / FR-1, FR-2 |
|
||||
| AC-2 | BR-3 / FR-4 |
|
||||
| AC-3 | BR-2 / FR-3 |
|
||||
| AC-4 | BR-3, BR-5 / FR-4 |
|
||||
| AC-5 | BR-6 / FR-6 |
|
||||
| AC-6 | BR-4 / FR-5 |
|
||||
| AC-7 | NFR-2 / FR-1 |
|
||||
| AC-8 | NFR-3, NFR-4 / FR-7 |
|
||||
| AC-9 | NFR-1 / FR-7 |
|
||||
| AC-10 | BR-5 / FR-3 |
|
||||
112
docs/work-items/ORCH-124/04-test-plan.yaml
Normal file
112
docs/work-items/ORCH-124/04-test-plan.yaml
Normal file
@@ -0,0 +1,112 @@
|
||||
work_item: ORCH-124
|
||||
stage: analysis
|
||||
author_agent: analyst
|
||||
status: ready-for-review
|
||||
created_at: 2026-06-16
|
||||
model_used: claude-opus-4-8
|
||||
title: "Serial-gate wait/pause semantics — paused predecessor must not block urgent successor"
|
||||
framework: pytest
|
||||
scope: >
|
||||
Покрывает определение "активной задачи" в serial-gate (build_claim_clause /
|
||||
repo_has_active_task / _per_repo_snapshot), durable явный сигнал паузы и его учёт в
|
||||
горячем SQL claim, сохранность анти-stale-base ORCH-088, явных блоков (freeze/task_deps),
|
||||
наблюдаемости GET /queue, never-raise/fail-directions и kill-switch. Вне покрытия:
|
||||
изменения STAGE_TRANSITIONS/QG_CHECKS/check_* (их НЕТ), сетевые вызовы Plane API на hot-path
|
||||
(запрещены — проверяется их ОТСУТСТВИЕ), реальный прод-деплой/рестарт.
|
||||
notes: >
|
||||
TC-01 — ОБЯЗАТЕЛЬНЫЙ регресс-тест инцидента ORCH-116/ORCH-123: красный до фикса, зелёный
|
||||
после. Точные имена флагов/колонок/эндпоинтов и SQL-форма предиката зависят от выбранного
|
||||
механизма (ADR архитектора) — тест-план фиксирует ПОВЕДЕНИЕ, не реализацию. Тесты
|
||||
работают на временной SQLite-БД без сети/Plane/прода (паттерн tests/test_serial_gate*.py).
|
||||
Полный регресс tests/ должен оставаться зелёным (особенно test_serial_gate*.py).
|
||||
|
||||
tests:
|
||||
- id: TC-01
|
||||
type: integration
|
||||
description: "РЕГРЕСС (обязательный): репо с более ранней приостановленной задачей A + более поздняя срочная B → claim_next_job выбирает analyst-job B (гейт открыт). Красный до фикса, зелёный после. Воспроизводит ORCH-116/ORCH-123."
|
||||
module: tests/test_orch124_serial_gate_pause.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "Предшественник в Backlog (по явному намерению) не считается активным: build_claim_clause не блокирует analyst-job успешника."
|
||||
module: tests/test_orch124_serial_gate_pause.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "Предшественник в Needs-Input (по явному намерению) не блокирует успешника — параллельно AC-1 для иного wait-состояния."
|
||||
module: tests/test_orch124_serial_gate_pause.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "АНТИ-РЕГРЕСС ORCH-088: нормально исполняемый предшественник (не на паузе, стадия вне done/cancelled) ПО-ПРЕЖНЕМУ блокирует analyst-job успешника (FIFO цел)."
|
||||
module: tests/test_orch124_serial_gate_pause.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "Пауза требует явного durable намерения: задача без операторского сигнала паузы остаётся активной (гейт держится); сигнал паузы DB-резолвим."
|
||||
module: tests/test_orch124_serial_gate_pause.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "Durable: сигнал паузы переживает пересоздание соединения/рестарт (читается из БД, не из памяти процесса)."
|
||||
module: tests/test_orch124_serial_gate_pause.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "Возобновление (resume) восстанавливает участие задачи в serial-gate (держит гейт / ре-входит в FIFO согласно ADR); нет вечного обхода."
|
||||
module: tests/test_orch124_serial_gate_pause.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "Явные блоки сохранены: активный repo_freeze продолжает блокировать claim успешника; пауза freeze не обходит."
|
||||
module: tests/test_orch124_serial_gate_pause.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "Явные блоки сохранены: незавершённая объявленная зависимость (job_deps/task_deps) продолжает блокировать claim; пауза dependency не обходит."
|
||||
module: tests/test_orch124_serial_gate_pause.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "GET /queue snapshot: приостановленный предшественник НЕ показывается как active_task; причина ожидания успешника корректна (active-task/paused-predecessor/freeze/dependency); существующие ключи snapshot сохранены."
|
||||
module: tests/test_orch124_serial_gate_pause.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-11
|
||||
type: unit
|
||||
description: "Согласованность трёх точек: build_claim_clause, repo_has_active_task и _per_repo_snapshot дают один и тот же вердикт 'активна' на одном входе (анти-дрейф)."
|
||||
module: tests/test_orch124_serial_gate_pause.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-12
|
||||
type: unit
|
||||
description: "Hot-path offline: claim_next_job + build_claim_clause резолвят паузу без сетевого вызова (Plane API не вызывается); claim работает при недоступном Plane."
|
||||
module: tests/test_orch124_serial_gate_pause.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-13
|
||||
type: unit
|
||||
description: "never-raise / fail-directions: инъекция ошибки в логику паузы → build_claim_clause fail-OPEN ('' фрагмент, очередь не клинит), freeze-решение fail-CLOSED; ни одна публичная функция не бросает."
|
||||
module: tests/test_orch124_serial_gate_pause.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-14
|
||||
type: unit
|
||||
description: "Kill-switch / нейтральность: при выключенном под-флаге паузы (и/или serial_gate_enabled) поведение serial-gate байт-в-байт как ORCH-088/090; вне области репо — не затронуто (enduro)."
|
||||
module: tests/test_orch124_serial_gate_pause.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-15
|
||||
type: unit
|
||||
description: "Структурный анти-регресс: STAGE_TRANSITIONS, состав QG_CHECKS, имена check_* и machine-verdict ключи не изменены; схемы существующих таблиц tasks/jobs/job_deps/repo_freeze не сломаны."
|
||||
module: tests/test_orch124_serial_gate_pause.py
|
||||
expected: PASS
|
||||
@@ -0,0 +1,300 @@
|
||||
---
|
||||
work_item: ORCH-124
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-16
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# ADR-001: Serial-gate «пауза без блокировки» — явный per-task park-сигнал (ORCH-124)
|
||||
|
||||
Work Item: **ORCH-124** · Repo: **orchestrator** (self-hosting) · Стадия: architecture
|
||||
Связь: BRD `01-brd.md`, ТЗ `02-trz.md`, AC `03-acceptance-criteria.md`, данные `08-data-requirements.md`, риски `10-tech-risks.md`.
|
||||
Сквозная регистрация: `docs/architecture/adr/adr-0051-serial-gate-pause-without-blocking.md`.
|
||||
|
||||
## Статус
|
||||
Proposed
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Баг (метка `Bug`, эскалирован в full-cycle — `escalate: full-cycle`, ADR-001 D5 ORCH-019): по сути
|
||||
**архитектурный** дефект семантики serial-gate, требующий ADR (выбор механизма «паузы» + разрешение
|
||||
конфликта с анти-stale-base ORCH-088).
|
||||
|
||||
**Симптом (инцидент ORCH-116/ORCH-123, установленный факт).** Задачу-предшественника ORCH-116
|
||||
поставили на паузу (перевели в Plane Blocked/Backlog), чтобы пропустить вперёд срочный фикс ORCH-123.
|
||||
`serial_gate` **по-прежнему считал ORCH-116 активной** и держал analyst-job ORCH-123 в `queued` — срочный
|
||||
фикс не стартовал, пока ORCH-116 формально не `done`/`cancelled`.
|
||||
|
||||
**Причина (верифицировано в коде).** `serial_gate.py` определяет «активную задачу репо» **исключительно
|
||||
по машинной стадии** `tasks.stage NOT IN ('done','cancelled')` в трёх точках:
|
||||
- `build_claim_clause()` — горячий SQL-фрагмент в `db.claim_next_job` (`src/serial_gate.py:274-278`):
|
||||
`EXISTS (SELECT 1 FROM tasks t2 WHERE t2.repo = jobs.repo AND t2.id < jobs.task_id AND
|
||||
t2.stage NOT IN ('done','cancelled'))`;
|
||||
- `repo_has_active_task()` — Python-зеркало (`src/serial_gate.py:117-127`);
|
||||
- `_per_repo_snapshot()` — выбор `active_task` для `GET /queue` (`src/serial_gate.py:340-344`).
|
||||
|
||||
Plane-статусы Backlog/Blocked/Needs-Input — **слой B (индикация), ORCH-066** — **не меняют `tasks.stage`
|
||||
(слой A)**. Сеттеры `set_issue_blocked`/`set_issue_needs_input` делают только `PATCH` Plane-статуса; у
|
||||
таблицы `tasks` **нет колонки статуса** (`src/reconciler.py:322`: «`tasks` has no status column, so the
|
||||
live Plane state is the source of truth»). ⇒ для serial-gate приостановленная задача неотличима от активно
|
||||
исполняемой: её стадия вне `{done,cancelled}` ⇒ она «активна» ⇒ держит FIFO закрытым для всех более
|
||||
поздних analyst-job того же репо.
|
||||
|
||||
**Прецедент, который НЕ переиспользуем.** `reconciler` уже различает Blocked/Needs-Input
|
||||
(`_is_blocked_or_needs_input`, ORCH-060 Guard 2) — но **сетевым** запросом Plane. `serial_gate.build_claim_clause`
|
||||
врезан в `claim_next_job` — **offline hot-path** — и сетевого вызова позволить **не может** (NFR-2). Это и
|
||||
есть центральное расхождение: сигнал паузы есть в Plane, но недоступен горячему SQL гейта.
|
||||
|
||||
**Нужен** явный, durable, **DB-резолвимый** признак «пауза», который горячий SQL читает локально, при этом
|
||||
**не регрессирует** анти-stale-base ORCH-088 (R-1) и не ломает гармонизированный терминал `{done,cancelled}`
|
||||
(ORCH-090 / adr-0026, NFR-4).
|
||||
|
||||
---
|
||||
|
||||
## Решение
|
||||
|
||||
### Сводка
|
||||
Вводится **явный per-task park-сигнал** — аддитивная нуллабельная колонка **`tasks.paused_at TEXT`**
|
||||
(NULL = не на паузе; non-NULL = поставлена оператором на паузу) — и **новая ортогональная ось
|
||||
планировщика «пауза»**, отделённая от оси «терминальность». «Активная задача» для serial-gate
|
||||
переопределяется как **`не терминальна И не на паузе`** во всех трёх точках; терминал `{done,cancelled}`
|
||||
в `serial_gate`/`task_deps`/`stages.py` остаётся **байт-в-байт**. Намерение паузы задаётся явными
|
||||
операторскими эндпоинтами `POST /serial-gate/pause|resume` (по образцу `POST /serial-gate/unfreeze`).
|
||||
Анти-stale-base при возобновлении обеспечивают **существующие** механизмы (отложенный срез ветки ORCH-088
|
||||
+ pre-merge `auto_rebase_onto_main` под merge-lease ORCH-026/093 + merge-gate re-test ORCH-110) — **новой
|
||||
rebase-машинерии не вводится**. Аддитивно, под независимым под-флагом, never-raise, restart-safe.
|
||||
`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict / схемы существующих таблиц —
|
||||
**не трогаются** (правка планировщика очереди, не Quality Gate).
|
||||
|
||||
### D1 — Механизм: явный per-task pause-флаг (а не release-on-status / task_deps) (FR-3, BR-2)
|
||||
|
||||
**Решение: явный durable DB-сигнал «park» на уровне задачи**, инициируемый оператором через API, а **не**
|
||||
маппинг Plane-статуса и **не** `task_deps`.
|
||||
|
||||
Обоснование выбора (см. «Альтернативы» для отклонённых):
|
||||
- **Чистое намерение, отличное от cancel и от kill-switch** (BR-2): park ≠ терминал (`cancelled`),
|
||||
≠ глобальное выключение гейта (`serial_gate_enabled=False`).
|
||||
- **DB-резолвимо и offline** (NFR-2): сигнал — колонка локальной БД, читается горячим SQL без сети.
|
||||
- **Не перегружает Plane-статус** (ORCH-066/059): pause НЕ управляется сменой Plane-статуса. Оператор
|
||||
может **дополнительно** перевести карточку в Blocked для индикации, но это косметика — гейт ею не
|
||||
управляется. Это прямое следование решению ORCH-088 D4 (снятие freeze Plane-жестом отвергнуто как
|
||||
анти-паттерн ORCH-059).
|
||||
- **Durable/идемпотентно/restart-safe** (BR-2/R-3): колонка переживает рестарт; не зависит от доставки
|
||||
webhook (потерянный webhook не рассинхронит сигнал).
|
||||
|
||||
### D2 — Хранилище: аддитивная колонка `tasks.paused_at` (а не отдельная таблица) (NFR-3)
|
||||
|
||||
**Решение: нуллабельная колонка `tasks.paused_at TEXT`** через `_ensure_column` — паттерн уже
|
||||
существующих per-task durable-сигналов `tasks.cancelled_at` / `tasks.cancel_requested_at` / `tasks.track`
|
||||
(`src/db.py:141-149`). NULL = не на паузе; ISO-таймстамп = на паузе (момент постановки, наблюдаемость).
|
||||
|
||||
Почему **колонка**, а не таблица по образцу `repo_freeze`:
|
||||
- Пауза — **per-task** сигнал (кардинальность 1:1 с задачей), в отличие от `repo_freeze` (per-**repo**,
|
||||
append-only журнал истории заморозок).
|
||||
- Горячий SQL `build_claim_clause` уже сканирует `tasks t2` — добавление `AND t2.paused_at IS NULL`
|
||||
внутрь существующего `EXISTS`-подзапроса — **минимальная, offline, index-дружественная** правка без
|
||||
лишнего JOIN/EXISTS. Таблица потребовала бы доп. подзапрос в горячем пути.
|
||||
- Схемы существующих таблиц (`tasks`/`jobs`/`job_deps`/`repo_freeze`) не меняются деструктивно; миграция —
|
||||
идемпотентный `_ensure_column` (no-op на уже мигрированной БД), безопасна на общей прод-БД (enduro не
|
||||
затронут). Детали — `08-data-requirements.md`.
|
||||
|
||||
### D3 — Пауза — ортогональная ось; терминал `{done,cancelled}` не трогается (NFR-4, FR-6 — критично)
|
||||
|
||||
**Решение: «активность» для serial-gate = `не терминальна И не на паузе`; терминал остаётся
|
||||
`{done,cancelled}` без изменений.**
|
||||
|
||||
Это явная, задокументированная дивергенция, которую требует NFR-4. Две независимые оси:
|
||||
|
||||
| Ось | Предикат | Где используется | Меняется ORCH-124? |
|
||||
|-----|----------|------------------|--------------------|
|
||||
| **Терминальность** | `stage IN ('done','cancelled')` | `serial_gate` + `task_deps` + `stages.py` (adr-0026) | **НЕТ — байт-в-байт** |
|
||||
| **Пауза (новая)** | `paused_at IS NOT NULL` | **только** FIFO «active» предикат `serial_gate` | да (аддитивно) |
|
||||
|
||||
Следствия (закрывает R-4 и FR-6):
|
||||
- **serial-gate** «активная задача» = `stage NOT IN ('done','cancelled') AND paused_at IS NULL`. Пауза
|
||||
выводит предшественника из FIFO-учёта serial-gate.
|
||||
- **task_deps** НЕ трогается: остаётся чисто терминальным (`stage NOT IN ('done','cancelled')`). Явно
|
||||
объявленная зависимость (`job_deps`) на **приостановленную** задачу **по-прежнему блокирует** зависимый
|
||||
job — пауза НЕ обходит `task_deps` (FR-6/AC-5). Пауза («пропустите меня в FIFO») и dependency
|
||||
(«B реально нужен результат A») — разные оси.
|
||||
- **stages.py** `STAGE_TRANSITIONS` не трогается: пауза — не стадия и не ребро (нет нового стока/перехода).
|
||||
|
||||
### D4 — Три точки serial-gate правятся согласованно (FR-1, FR-2)
|
||||
|
||||
Один предикат «активна» во всех трёх точках (анти-дрейф: одинаковый ответ на одинаковый вход), под
|
||||
под-флагом паузы (D6):
|
||||
|
||||
1. **`build_claim_clause()`** — в `active_clause` добавить терм `AND t2.paused_at IS NULL` (только когда
|
||||
слой паузы включён; иначе фрагмент строится байт-в-байт как ORCH-088/090):
|
||||
```sql
|
||||
EXISTS (SELECT 1 FROM tasks t2
|
||||
WHERE t2.repo = jobs.repo AND t2.id < jobs.task_id
|
||||
AND t2.stage NOT IN ('done','cancelled')
|
||||
{pause_term}) -- pause_term = " AND t2.paused_at IS NULL" | ""
|
||||
```
|
||||
Инварианты сохранены: гейт только для `jobs.agent='analyst'`; FIFO `t2.id < jobs.task_id` (R-7,
|
||||
нет самоблокировки); job'ы активной задачи проходят.
|
||||
2. **`repo_has_active_task()`** — добавить `AND paused_at IS NULL` (под тем же под-флагом).
|
||||
3. **`_per_repo_snapshot()`** — выбор `active_task` исключает приостановленные (`AND paused_at IS NULL`),
|
||||
и отдельно перечисляет приостановленные (D5).
|
||||
|
||||
### D5 — Наблюдаемость: причина ожидания + список paused (FR-5, BR-4, AC-6)
|
||||
|
||||
`_per_repo_snapshot` расширяется **аддитивно** (существующие ключи `active_task`/`waiting`/`frozen`/
|
||||
`frozen_reason`/`frozen_at` — байт-в-байт BC):
|
||||
- `active_task` больше **не** показывает приостановленную задачу (D4.3).
|
||||
- Новый ключ `paused: [{work_item_id, stage, paused_at}, …]` — приостановленные незавершённые задачи репо
|
||||
(видимы, но не как `active_task`).
|
||||
- Для каждого `waiting`-job добавляется `reason` — причина, по которой job НЕ claimable, с приоритетом:
|
||||
**`freeze`** (активен `repo_freeze`) → **`dependency`** (незавершённая `task_deps` для task этого job)
|
||||
→ **`active-task`** (есть более ранняя **не-приостановленная** незавершённая задача) → **`null`**
|
||||
(claimable). Категория `paused-predecessor` из ТЗ FR-5 — наблюдается через ключ `paused` (приостановленный
|
||||
предшественник по дизайну **не** блокирует ⇒ не является причиной ожидания после фикса).
|
||||
|
||||
### D6 — Условность: независимый под-флаг `serial_gate_pause_enabled` (FR-7, NFR-3)
|
||||
|
||||
По образцу `serial_gate_freeze_enabled` (`src/config.py:1006`) — независимый тумблер для поэтапного раската
|
||||
и обратимости:
|
||||
- `serial_gate_pause_enabled: bool = True` (env `ORCH_SERIAL_GATE_PAUSE_ENABLED`). Дефолт `True` безопасен:
|
||||
пока ни одна задача не на паузе (`paused_at` всегда NULL), предикат `AND t2.paused_at IS NULL` всегда
|
||||
истинен ⇒ поведение **идентично** ORCH-088/090 ⇒ **истинный no-op** до явной операторской паузы (enduro
|
||||
не затронут). Постановка на паузу возможна только через явный эндпоинт (D7).
|
||||
- `False` ⇒ pause-терм **опускается** из SQL, эндпоинты pause/resume — no-op-предупреждение; serial-gate
|
||||
ведёт себя **байт-в-байт** как ORCH-088/090 (осознанный rollback-режим — возврат к текущему багу, не
|
||||
дефолт).
|
||||
- Хелпер `serial_gate._pause_layer_enabled()` (never-raise, зеркало `_freeze_layer_enabled`).
|
||||
- **Область** — переиспользует `serial_gate_repos` (пауза — уточнение того же гейта; новый `*_repos`
|
||||
**не** вводится — принцип минимума конфигурации). Под-флаг паузы независим от `serial_gate_freeze_enabled`,
|
||||
но подчинён kill-switch `serial_gate_enabled` (при выключенном гейте паузы нет смысла).
|
||||
|
||||
### D7 — Операторские эндпоинты pause/resume (FR-3, BR-5, AC-3, AC-10)
|
||||
|
||||
По образцу `POST /serial-gate/unfreeze` (`src/main.py:350-376`), never-raise, с Telegram-подтверждением:
|
||||
- **`POST /serial-gate/pause?work_item=<id>`** → `db.set_task_paused(task_id)` (`paused_at=datetime('now')`,
|
||||
идемпотентно). Применимо к **нетерминальной** задаче (паузить `done`/`cancelled` — no-op + явный ответ).
|
||||
Возвращает `{ok, work_item, task_id, paused_at}`.
|
||||
- **`POST /serial-gate/resume?work_item=<id>`** → `db.clear_task_paused(task_id)` (`paused_at=NULL`).
|
||||
Возобновлённая задача снова участвует в serial-gate (AC-10): если ещё в `analysis` без ветки —
|
||||
ре-входит в FIFO с отложенным срезом ветки; если уже материализована — держит гейт как активная,
|
||||
её свежесть гарантирует merge-gate (D8). Возвращает `{ok, work_item, task_id, paused_at: null}`.
|
||||
- DB-хелперы `db.set_task_paused`/`db.clear_task_paused`/`db.is_task_paused` (по образцу
|
||||
`set_task_track`/`get_task_track`, `src/db.py:740-757`). never-raise.
|
||||
- Освобождение гейта — **только** по этому явному durable намерению; эвристического само-распаузивания
|
||||
нет (AC-3, R-2).
|
||||
|
||||
### D8 — Анти-stale-base при возобновлении: переиспользуем существующие механизмы (FR-4, R-1 — критично)
|
||||
|
||||
**Решение: пауза «демотирует» задачу в FIFO; свежесть базы при возобновлении гарантируют УЖЕ
|
||||
существующие механизмы — новой rebase-машинерии НЕ вводится.**
|
||||
|
||||
Два случая возобновления:
|
||||
1. **Пауза, пока задача ещё в `analysis` с queued analyst-job и НЕматериализованной веткой** (отложенный
|
||||
срез ORCH-088 D1): при resume срез ветки происходит на момент claim analyst-job
|
||||
(`launcher._materialize_deferred_branch`) от **тогда-актуального** `origin/main` — который уже содержит
|
||||
любого успешника, смерженного за время паузы. База **структурно свежая** ⇒ stale-base невозможна.
|
||||
2. **Пауза после материализации ветки** (development/review/testing/deploy-staging): ветка уже срезана от
|
||||
более раннего `main`. За время паузы успешник может смержиться ⇒ `main` уходит вперёд. При resume, когда
|
||||
задача дойдёт до merge-gate (`deploy-staging → deploy`), **существующий безусловный pre-merge
|
||||
`auto_rebase_onto_main` под merge-lease** (ORCH-026/088/093) перебазирует ветку на актуальный `main`, а
|
||||
**merge-gate re-test** (ORCH-110) перепрогоняет сюиту на перебазированном HEAD. Свежесть обеспечивается
|
||||
на merge, **не обходится**.
|
||||
|
||||
Итог (разрешение конфликта R-1): пауза меняет **только порядок FIFO** (кто держит гейт), но **не**
|
||||
контракт свежести на merge. Нормально исполняемая задача (`paused_at IS NULL`) по-прежнему держит гейт ⇒
|
||||
анти-stale-base для нормального случая (BR-3/AC-2) не регрессирует. Порядок merge при «B обгоняет
|
||||
паузнутую A» = B, затем A (A ребейзится на B) — ровно намерение оператора. Проверяемо тестом по контракту
|
||||
ADR (AC-4).
|
||||
|
||||
### D9 — never-raise и сохранённые fail-directions (NFR-1, AC-9)
|
||||
|
||||
- **`build_claim_clause`** остаётся **fail-OPEN**: pause-терм строится **внутри** существующего
|
||||
`try/except`; любая ошибка (в т.ч. в pause-подвыражении) → `""` → claim без гейта (не заклинить очередь
|
||||
всех проектов, AC-8). Направление не инвертируется.
|
||||
- **Freeze** остаётся **fail-CLOSED** (`is_repo_frozen`, AC-9) — pause-логика его не касается.
|
||||
- Pause-зеркало/снапшот/мутаторы never-raise → консервативная деградация (на ошибке чтения паузы в зеркале
|
||||
— «не на паузе», т.е. задача считается активной = гейт скорее закрыт = анти-stale-base-safe).
|
||||
|
||||
---
|
||||
|
||||
## Точки врезки (для разработчика)
|
||||
|
||||
| Файл | Изменение |
|
||||
|------|-----------|
|
||||
| `src/db.py` | `_ensure_column(conn, "tasks", "paused_at", "TEXT")` (D2); хелперы `set_task_paused`/`clear_task_paused`/`is_task_paused` (D7) |
|
||||
| `src/serial_gate.py` | `_pause_layer_enabled()` (D6); pause-терм в `build_claim_clause` (D4.1); `AND paused_at IS NULL` в `repo_has_active_task` (D4.2) и `_per_repo_snapshot` (D4.3); ключ `paused` + `reason` в снапшоте (D5). Маркер `ORCH-124` рядом с `ORCH-088`/`ORCH-090` |
|
||||
| `src/config.py` | `serial_gate_pause_enabled: bool = True` (D6) |
|
||||
| `src/main.py` | `POST /serial-gate/pause`, `POST /serial-gate/resume` (D7); блок `serial_gate` в `GET /queue` уже зовёт `snapshot()` (D5 — расширение прозрачно) |
|
||||
| `tests/test_orch124_serial_gate_pause.py` | **новый** — AC-1 регресс инцидента (красный до фикса, зелёный после), AC-2…AC-10 |
|
||||
| `docs/architecture/README.md`, `internals.md`, `CHANGELOG.md` | обновить раздел serial-gate + ось паузы (golden source) |
|
||||
|
||||
`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи / `start_pipeline` / `launcher`
|
||||
deferred-branch / merge-gate / схемы существующих таблиц — **не трогаются**.
|
||||
|
||||
---
|
||||
|
||||
## Альтернативы (отклонены)
|
||||
|
||||
- **Release-on-status (Plane Blocked/Backlog → DB-сигнал через webhook)** — отвергнуто: перегружает
|
||||
Plane-статус управлением конвейером (нарушает слой A/B ORCH-066 и анти-паттерн ORCH-059, ровно как
|
||||
ORCH-088 D4 отверг снятие freeze Plane-жестом); хрупко к потере webhook (R-3); намерение не доступно
|
||||
offline hot-path (NFR-2).
|
||||
- **Переиспользовать `task_deps`** — отвергнуто: `task_deps` моделирует «B ждёт A», не умеет выразить
|
||||
«A на паузе, остальных пропустить» (обратное направление). Кроме того, пауза НЕ должна обходить
|
||||
объявленную зависимость (FR-6) — это разные оси (D3).
|
||||
- **Отдельная таблица `task_hold` (по образцу `repo_freeze`)** — отвергнуто: пауза per-task 1:1; колонка
|
||||
минимальнее и не требует JOIN в горячем SQL (D2). `repo_freeze` — таблица, т.к. per-repo append-only журнал.
|
||||
- **Реюз `repo_freeze` для паузы** — отвергнуто: freeze замораживает **весь репо** (блокирует всех
|
||||
успешников) — противоположность «пропустить срочного успешника».
|
||||
- **Расширить терминал `{done,cancelled,paused}`** — отвергнуто: пауза не терминальна; это сломало бы
|
||||
`task_deps`/`stages.py` (NFR-4). Пауза — ортогональная ось, не терминальное состояние (D3).
|
||||
- **Новая rebase-машинерия при resume** — отвергнуто как избыточное: существующие отложенный срез +
|
||||
merge-gate rebase/re-test уже покрывают свежесть (D8).
|
||||
|
||||
---
|
||||
|
||||
## Последствия
|
||||
|
||||
### Плюсы
|
||||
- **+** Закрывает инцидент ORCH-116/ORCH-123 (AC-1): срочный фикс стартует поверх паузнутого предшественника.
|
||||
- **+** Чистое, явное, durable намерение паузы, отличное от cancel и kill-switch (BR-2); webhook-независимо
|
||||
(R-3); offline hot-path (NFR-2).
|
||||
- **+** Терминал `{done,cancelled}` и `task_deps`/`stages.py` — байт-в-байт (NFR-4); пауза НЕ обходит
|
||||
freeze/dependency (FR-6).
|
||||
- **+** Анти-stale-base (ORCH-088) не регрессирует — нормальная задача держит гейт; resume опирается на
|
||||
существующие отложенный срез + merge-gate rebase/re-test (D8, AC-2/AC-4).
|
||||
- **+** Переиспользует проверенные паттерны (`cancelled_at`-колонка, `unfreeze`-эндпоинт, leaf never-raise,
|
||||
`/queue`-снапшот). `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схемы существующих таблиц — без изменений.
|
||||
- **+** Истинный no-op для enduro при дефолтном флаге (пауза не выставлена) и байт-в-байт откат при флаге off.
|
||||
|
||||
### Минусы / ограничения
|
||||
- **−** Пауза — операторское действие через API (не Plane-жест). Митигейшн: симметрично существующему
|
||||
`unfreeze`; задокументировано в README + Telegram-подтверждение; оператор может дополнительно перевести
|
||||
карточку в Blocked для индикации.
|
||||
- **−** «Залипшая пауза» при невнимании оператора (resume забыт) → задача навсегда вне гейта. Митигейшн:
|
||||
наблюдаемость (`paused` в `GET /queue`); resume идемпотентен; намерение durable, не теряется (R-2).
|
||||
- **−** Горячий SQL serial-gate теперь несёт 3 маркера (`ORCH-088`/`ORCH-090`/`ORCH-124`) ⇒ сводный
|
||||
сквозной ADR `adr-0051` (анти-археология, TRACEABILITY.md).
|
||||
|
||||
### Откат
|
||||
Полный откат — `ORCH_SERIAL_GATE_PAUSE_ENABLED=false` (serial-gate 1:1 как ORCH-088/090; pause-терм
|
||||
опущен, эндпоинты no-op). Колонка `tasks.paused_at` инертна при выключенном под-флаге. Глубже —
|
||||
`serial_gate_enabled=false` (весь гейт инертен, как до ORCH-088).
|
||||
|
||||
---
|
||||
|
||||
## Ссылки
|
||||
- BRD: `docs/work-items/ORCH-124/01-brd.md` · ТЗ: `02-trz.md` · Acceptance: `03-acceptance-criteria.md`
|
||||
- Данные: `docs/work-items/ORCH-124/08-data-requirements.md` · Риски: `10-tech-risks.md`
|
||||
- Сквозной ADR: `docs/architecture/adr/adr-0051-serial-gate-pause-without-blocking.md`
|
||||
- Сверено по коду: `src/serial_gate.py` (117-127, 274-278, 340-344), `src/db.py` (claim_next_job
|
||||
1043-1110, `_ensure_column`/tasks-колонки 141-149, 740-757), `src/main.py` (350-376), `src/config.py`
|
||||
(1004-1006), `src/reconciler.py:322`
|
||||
- Базовые решения: adr-0017 (serial-gate ORCH-088), adr-0026 (терминал `{done,cancelled}` ORCH-090),
|
||||
adr-0015 (task-deps ORCH-026), adr-0027 (merge-актор rebase/retry ORCH-093), adr-0042 (merge-gate
|
||||
re-test ORCH-110)
|
||||
</content>
|
||||
</invoke>
|
||||
54
docs/work-items/ORCH-124/08-data-requirements.md
Normal file
54
docs/work-items/ORCH-124/08-data-requirements.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
work_item: ORCH-124
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-16
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# 08 — Требования к данным: ORCH-124 — per-task park-сигнал serial-gate
|
||||
|
||||
Work Item: **ORCH-124** · Repo: **orchestrator** · Стадия: architecture
|
||||
|
||||
> When-applicable / информационный (гейтом не парсится).
|
||||
|
||||
## Изменения схемы БД
|
||||
|
||||
**Одна аддитивная нуллабельная колонка** на существующей таблице `tasks` (никаких новых таблиц):
|
||||
|
||||
| Таблица | Колонка | Тип / дефолт | Семантика |
|
||||
|---------|---------|--------------|-----------|
|
||||
| `tasks` | `paused_at` | `TEXT` (по умолчанию отсутствует → `NULL`) | `NULL` = не на паузе; ISO-таймстамп (`datetime('now')`) = задача поставлена оператором на паузу (park) |
|
||||
|
||||
Миграция — идемпотентный `_ensure_column(conn, "tasks", "paused_at", "TEXT")` в `init_db()`, ровно по
|
||||
образцу `tasks.cancelled_at` / `tasks.cancel_requested_at` / `tasks.track` (`src/db.py:141-149`). На уже
|
||||
мигрированной БД — no-op.
|
||||
|
||||
**Индекс не требуется.** Горячий SQL `build_claim_clause` сканирует `tasks t2` уже сегодня (по `repo`/`id`);
|
||||
терм `AND t2.paused_at IS NULL` — дополнительный фильтр в существующем `EXISTS`-подзапросе, не новый план
|
||||
доступа. Кардинальность `tasks` per-repo мала; добавление индекса — преждевременная оптимизация (принцип
|
||||
минимума).
|
||||
|
||||
## Новые/изменённые сущности
|
||||
|
||||
- **`tasks.paused_at`** — единственное durable хранилище намерения паузы. Запись — `db.set_task_paused`
|
||||
(`paused_at=datetime('now')`); сброс — `db.clear_task_paused` (`paused_at=NULL`); чтение —
|
||||
`db.is_task_paused` и SQL-предикат serial-gate. Все хелперы never-raise.
|
||||
- **Инвариант оси:** `paused_at` — **ортогональная** ось «пауза», независимая от оси «терминальность»
|
||||
(`stage IN ('done','cancelled')`). serial-gate «активна» = `stage NOT IN ('done','cancelled') AND
|
||||
paused_at IS NULL`. `task_deps`/`stages.py` колонку `paused_at` **не читают** (терминал не трогается,
|
||||
NFR-4).
|
||||
- **Существующие таблицы** (`jobs` / `job_deps` / `repo_freeze` / `agent_runs`) — без изменений.
|
||||
|
||||
## Совместимость данных / миграции
|
||||
|
||||
- **Аддитивно и идемпотентно:** `_ensure_column` — no-op на уже-мигрированной БД; новая колонка
|
||||
дефолтит в `NULL` для всех существующих строк ⇒ все текущие задачи считаются «не на паузе» ⇒ поведение
|
||||
до ORCH-124 сохраняется до первой явной операторской паузы.
|
||||
- **Restart-safe / durable:** значение в БД переживает рестарт процесса/контейнера (BR-2, R-3).
|
||||
- **Общая прод-БД (self-hosting):** колонка добавляется на общей БД; при дефолтном `serial_gate_pause_enabled`
|
||||
и отсутствии паузнутых задач — нулевая регрессия для enduro (`paused_at` везде `NULL`).
|
||||
- **Откат:** колонка инертна при `ORCH_SERIAL_GATE_PAUSE_ENABLED=false` (pause-терм опускается из SQL).
|
||||
Колонку можно оставить (безвредна); деструктивный drop не требуется и не рекомендуется на прод-БД.
|
||||
</content>
|
||||
41
docs/work-items/ORCH-124/10-tech-risks.md
Normal file
41
docs/work-items/ORCH-124/10-tech-risks.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
work_item: ORCH-124
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-16
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# 10 — Технические риски: ORCH-124 — serial-gate «пауза без блокировки»
|
||||
|
||||
Work Item: **ORCH-124** · Repo: **orchestrator** · Стадия: architecture
|
||||
|
||||
> Информационный (гейтом не парсится). Перечисляет риски реализации и митигейшн; покрывает R-1…R-5 из BRD §8.
|
||||
|
||||
## Реестр рисков
|
||||
|
||||
| ID | Риск | Вер. | Влия. | Митигейшн |
|
||||
|----|------|------|-------|-----------|
|
||||
| TR-1 (= R-1, ключевой) | **Пауза vs анти-stale-base ORCH-088.** Успешник срезает ветку от `main` без кода паузнутого предшественника; при возобновлении предшественника возможна stale-база/затирание. | Сред. | Выс. | **D8:** новой rebase-машинерии нет — свежесть гарантируют существующие механизмы. Паузнутая-в-`analysis` задача при resume режет ветку отложенно (ORCH-088) от свежего `origin/main`. Материализованная — ребейзится на merge-gate (`auto_rebase_onto_main` под merge-lease ORCH-026/093) + re-test (ORCH-110). Нормальная задача (`paused_at IS NULL`) по-прежнему держит гейт (BR-3/AC-2). Тест AC-4. |
|
||||
| TR-2 (= R-2) | **Неявное/случайное освобождение гейта** (баг в детекте намерения) ослабит сериализацию для всех. | Низ. | Выс. | Освобождение **только** по явной операторской паузе через эндпоинт (D7); никакого эвристического само-распаузивания (AC-3). Дефолтный флаг безопасен (no-op без явной паузы). Тест AC-3. |
|
||||
| TR-3 (= R-3) | **Рассинхрон Plane-статус ↔ DB-сигнал паузы** (потерянный webhook оставит сигнал устаревшим). | Низ. | Сред. | Механизм НЕ опирается на webhook/Plane-статус (D1): сигнал — durable колонка `tasks.paused_at`, пишется прямым операторским вызовом, идемпотентен, переживает рестарт. Plane-статус — только косметическая индикация. |
|
||||
| TR-4 (= R-4) | **Регрессия гармонизированного терминала `{done,cancelled}`** в `task_deps`/`stages.py`. | Низ. | Выс. | **D3:** пауза — отдельная ось; терминал `{done,cancelled}` в `serial_gate`/`task_deps`/`stages.py` байт-в-байт. `task_deps` колонку `paused_at` не читает (паузнутая зависимость по-прежнему блокирует, FR-6/AC-5). Структурный тест AC-8. |
|
||||
| TR-5 (= R-5) | **Инверсия fail-direction** (ошибка в pause-ветке роняет claim или меняет fail-OPEN/fail-CLOSED). | Низ. | Выс. | **D9:** pause-терм внутри `try/except` `build_claim_clause` → fail-OPEN сохранён; freeze fail-CLOSED не тронут; все pause-функции never-raise. Тест AC-9 (инъекция ошибки → claim не падает). |
|
||||
| TR-6 | **«Залипшая пауза»** — оператор забыл `resume`, задача навсегда вне FIFO-учёта. | Сред. | Низ. | Наблюдаемость: ключ `paused` + `reason` в `GET /queue` (D5); `resume` идемпотентен; durable сигнал не теряется. Операторская гигиена (как «вечный freeze» ORCH-088). |
|
||||
| TR-7 | **Дрейф трёх точек** serial-gate (одна правится, другие нет → расхождение SQL-гейта и снапшота). | Низ. | Сред. | **D4:** один предикат «активна» во всех трёх точках, под одним под-флагом; анти-дрейф-тест (одинаковый ответ на одинаковый вход). |
|
||||
| TR-8 | **Миграция колонки на общей прод-БД** (self-hosting) затронет enduro. | Низ. | Сред. | Идемпотентный `_ensure_column`, дефолт `NULL` (паттерн `cancelled_at`/`track`); при дефолтном флаге и отсутствии паузнутых задач — нулевая регрессия (08-data-requirements). |
|
||||
|
||||
## Сводный вывод
|
||||
|
||||
Доминирующий класс — **семантический конфликт паузы с анти-stale-base (TR-1)**, разрешённый
|
||||
**переиспользованием существующих** механизмов свежести (D8), без новой машинерии. Остальные риски —
|
||||
стандартные для leaf-расширения serial-gate (fail-direction, дрейф точек, миграция), покрыты паттернами
|
||||
ORCH-088/090. Изменение **аддитивно, под независимым под-флагом, never-raise**, без правки
|
||||
`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/терминала/схем существующих таблиц.
|
||||
|
||||
**Эскалация `arch:major-change` не требуется** (нет новой стадии/компонента/QG/смены БД — аддитивная
|
||||
правка планировщика внутри существующего компонента serial-gate). Возврат в анализ не требуется (ТЗ
|
||||
удовлетворяется без нарушения принципов архитектуры). Остаточный риск для прод-конвейера (self-hosting) —
|
||||
**низкий**: дефолтное поведение — истинный no-op до явной операторской паузы; полный откат — один env-флаг.
|
||||
</content>
|
||||
150
docs/work-items/ORCH-124/12-review.md
Normal file
150
docs/work-items/ORCH-124/12-review.md
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
verdict: REQUEST_CHANGES
|
||||
work_item: ORCH-124
|
||||
stage: review
|
||||
author_agent: reviewer
|
||||
status: changes-requested
|
||||
created_at: 2026-06-16
|
||||
model_used: claude-opus-4-8
|
||||
type: review
|
||||
work_item_id: ORCH-124
|
||||
version: 2
|
||||
---
|
||||
|
||||
# Review ORCH-124 — serial-gate «пауза без блокировки» (per-task park-сигнал)
|
||||
|
||||
## Summary
|
||||
|
||||
Багфикс (метка `Bug`, эскалирован в full-cycle) инцидента ORCH-116/ORCH-123: serial-gate считал
|
||||
«активной» любую задачу со стадией вне `{done,cancelled}`, а Plane-статусы Backlog/Blocked/Needs-Input
|
||||
(слой B) не меняют `tasks.stage` (слой A) ⇒ приостановленный предшественник держал FIFO-гейт закрытым
|
||||
против срочного успешника. Решение — **ортогональная ось планировщика «пауза»**: аддитивная колонка
|
||||
`tasks.paused_at TEXT` + терм `AND paused_at IS NULL` во всех 3 точках определения «активной задачи»,
|
||||
операторские эндпоинты `POST /serial-gate/pause|resume`, под независимым под-флагом
|
||||
`serial_gate_pause_enabled`.
|
||||
|
||||
**Ядро реализации (код + ТЗ + ADR + тесты) — высокого качества и P0/P1-замечаний по сути фикса не
|
||||
имеет.** Реализация 1:1 соответствует ADR-001 (D1–D9), закрывает все FR/AC, машинные инварианты не
|
||||
тронуты, полный прогон `pytest tests/` зелёный (**2178 passed** на текущем HEAD; ранее красный
|
||||
order-dependent `test_orch123::test_r2…` исправлен изоляцией `repos_dir` в коммите `3a19728`).
|
||||
|
||||
**Но есть один P1 по оси документации:** PR меняет описанную в **витрине системы**
|
||||
(`docs/overview/tech-pipeline.md`) функциональность serial-gate (маршрут задач внутри репо), но
|
||||
витрину **не обновил** — нарушение норматива сопровождения `docs/overview/README.md` и правила агентов
|
||||
§6 (ORCH-011/ORCH-079). Предыдущий ревью (run 766) ошибочно заключил «serial-gate не фигурирует в
|
||||
витрине»; по факту serial-gate описан в 4 файлах витрины, и `tech-pipeline.md` несёт **абсолютное
|
||||
утверждение FIFO**, которое ORCH-124 делает неполным. **Вердикт: REQUEST_CHANGES** (исправляется
|
||||
обновлением витрины в этом же PR).
|
||||
|
||||
## Проверка по осям
|
||||
|
||||
### 1. Соответствие ТЗ (`02-trz.md` / `03-acceptance-criteria.md`) — ✅
|
||||
- FR-1 (пауза исключает из горячего SQL): `build_claim_clause` — терм `AND t2.paused_at IS NULL` внутри
|
||||
существующего `EXISTS`, offline, fail-OPEN сохранён (внутри того же try/except). ✓
|
||||
- FR-2 (зеркало + снапшот согласованы): один предикат во всех 3 точках; анти-дрейф — **TC-11**. ✓
|
||||
- FR-3 (явное durable DB-намерение): колонка `paused_at` + `set/clear/is_task_paused` + эндпоинты;
|
||||
durable через рестарт — **TC-06**. ✓
|
||||
- FR-4 (анти-stale-base при resume): через существующие механизмы (D8), без новой rebase-машинерии. ✓
|
||||
- FR-5 (причина ожидания): `_waiting_reason` (`freeze`→`dependency`→`active-task`→`null`) + ключ
|
||||
`paused` — **TC-10**. ✓
|
||||
- FR-6 (явные блоки сохранены): пауза НЕ обходит `repo_freeze`/`task_deps` — **TC-08/TC-09**
|
||||
(подтверждено: `task_deps` читает только терминал `{done,cancelled}`, не `paused_at`). ✓
|
||||
- FR-7 (условность/нейтральность): под-флаг + scope `serial_gate_repos`; kill-switch off байт-в-байт —
|
||||
**TC-14**. ✓
|
||||
- AC-1…AC-10 покрыты TC-01…TC-15 буквально по файлам/тестам; прогон зелёный.
|
||||
|
||||
### 2. Соответствие ADR (`06-adr/ADR-001`, сквозной `adr-0051`) — ✅
|
||||
Реализация = ADR 1:1: D1 (явный per-task флаг), D2 (`tasks.paused_at` через `_ensure_column`,
|
||||
паттерн `cancelled_at`/`track`), D3 (ортогональная ось — терминал `{done,cancelled}` **байт-в-байт**),
|
||||
D4 (3 точки согласованно), D5 (наблюдаемость), D6 (`serial_gate_pause_enabled`), D7 (эндпоинты),
|
||||
D8 (анти-stale-base реюзом), D9 (never-raise/fail-directions).
|
||||
- **Трассировка (ORCH-078/TRACEABILITY):** правка горячего SQL с маркерами `ORCH-088`/`ORCH-090` —
|
||||
инвариант FIFO (`t2.id < jobs.task_id`, R-7) и терминал-множество `{done,cancelled}` (adr-0026) **не
|
||||
сломаны** (структурный **TC-15** + сверка: `src/task_deps.py`/`src/stages.py` `paused_at` не читают).
|
||||
Маркер `ORCH-124` размещён рядом; сводный сквозной `adr-0051` заведён. ✓
|
||||
- Нет нарушений глобальных ADR. `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схемы
|
||||
существующих таблиц — не тронуты (`src/stages.py`, `src/qg/`, `src/frontmatter.py` вне диффа —
|
||||
подтверждено по `git diff --name-only`). ✓
|
||||
|
||||
### 3. Качество кода — ✅
|
||||
- Чисто, читаемо; docstrings на всех новых публичных функциях; маркеры/ссылки на ADR в коде.
|
||||
- never-raise на всех публичных функциях `serial_gate`/`db`-хелперах; hot-claim fail-OPEN,
|
||||
freeze fail-CLOSED — направления не инвертированы (**TC-13**, инъекция ошибки в `_pause_layer_enabled`
|
||||
→ `build_claim_clause()==""` → claim не падает).
|
||||
- Helper-сигнатуры выверены по коду: `db.get_task_by_work_item_id` существует (`SELECT *` ⇒ `paused_at`
|
||||
попадает в `dict`), `notifications.link_for`/`send_telegram` существуют. `resume`-эндпоинт сознательно
|
||||
НЕ гейтится `_pause_layer_enabled()` (позволяет снять «залипшую» паузу после выключения слоя) —
|
||||
корректное решение, не дефект.
|
||||
- **Багфикс-трек (ORCH-019 BR-4) — выполнен:** обязательный регресс-тест-фиксатор **TC-01**
|
||||
воспроизводит инцидент (красный до фикса — опирается на отсутствовавшие `set_task_paused`/`paused_at`;
|
||||
зелёный после); «до-фикса»-поведение бага дополнительно зафиксировано в **TC-14** (kill-switch off →
|
||||
паузнутая задача снова блокирует). ✓
|
||||
- Тесты содержательные (15 TC, не тривиальные): регресс, анти-регресс ORCH-088, durable/restart,
|
||||
resume, сохранность freeze/dependency, анти-дрейф 3 точек, offline hot-path, never-raise,
|
||||
kill-switch-нейтральность, структурный анти-регресс реестров/схем.
|
||||
- **Тест-гигиена (коммит `3a19728`):** изоляция `settings.repos_dir` в фикстуре
|
||||
`tests/test_orch123_staging_runner_exec.py` устранила order-dependent FAIL `test_r2…` (из-за фолбэка
|
||||
`check_staging_status` на реальный `<repos_dir>/orchestrator/.../15-staging-log.md` после мержа
|
||||
ORCH-123). Инвариант ORCH-123 R-2 сохранён; правка только теста — корректно. Чуть вне строгой области
|
||||
ORCH-124, но прозрачно задокументирована в CHANGELOG. Не блокирует.
|
||||
|
||||
### 4. Документация (обязательная ось) — ⚠️ обновлена НЕПОЛНО (см. P1)
|
||||
- ✅ `docs/architecture/README.md` (раздел serial-gate + ось «пауза», таблица БД `paused_at`, таблица
|
||||
API `/serial-gate/pause|resume`), `docs/architecture/internals.md` (ось «пауза» ⊥ «терминальность»),
|
||||
`CHANGELOG.md` (развёрнуто), `.env.example` (`ORCH_SERIAL_GATE_PAUSE_ENABLED`), ADR `06-adr/ADR-001` +
|
||||
сквозной `adr-0051`, `08-data-requirements.md` — обновлены качественно.
|
||||
- ❌ **`docs/overview/` (витрина системы) НЕ обновлена** при изменении описанного в ней маршрута задач
|
||||
serial-gate → **P1** (детали ниже). serial-gate присутствует в витрине (grep: `tech-pipeline.md`,
|
||||
`tech-data-model.md`, `tech-observability.md`, `tech-quality-security.md`).
|
||||
- ✅ Root `README.md` «Известные ограничения»: п.3 — про эпик ORCH-088 (пакетный автоном), данным
|
||||
багфиксом не закрывается → обновления не требует.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- Нет.
|
||||
|
||||
### P1 — Must fix
|
||||
- [ ] **Витрина системы `docs/overview/` не обновлена при изменении описанной в ней функциональности
|
||||
serial-gate (ORCH-011/ORCH-079, правило агентов §6 + норматив `docs/overview/README.md`).**
|
||||
`docs/overview/tech-pipeline.md` §«Последовательность внутри репозитория (serial gate)» (стр. 103–108)
|
||||
несёт **абсолютное** утверждение маршрута: *«Новая задача репозитория не входит в работу, пока не
|
||||
завершена более ранняя (FIFO)»* и уже **перечисляет исключение freeze** («следующие задачи ждут»).
|
||||
ORCH-124 вводит **второе намеренное исключение** — оператор может поставить предшественника на паузу,
|
||||
и срочный успешник его **обгоняет**. После фикса утверждение FIFO неполно/вводит в заблуждение, а
|
||||
норматив сопровождения витрины (`docs/overview/README.md`: «маршруты задач → `tech-pipeline.md`»)
|
||||
требует синхронного обновления в том же PR.
|
||||
**Как исправить:** добавить в `tech-pipeline.md` (раздел serial gate) краткую фразу про ось «пауза
|
||||
без блокировки» (оператор паузит предшественника → срочный успешник обгоняет; пауза ≠ cancel, не
|
||||
обходит freeze/dependency), рядом с уже описанным исключением freeze. Рекомендуется заодно (та же ось,
|
||||
во избежание повторного REQUEST_CHANGES): `tech-data-model.md` — упомянуть durable-сигнал
|
||||
`tasks.paused_at` рядом с `repo_freeze`; `tech-observability.md` — ключ `paused`/`reason` в блоке
|
||||
`serial_gate` `GET /queue` и операторские эндпоинты `pause|resume`. Машинные доковые тесты
|
||||
(`test_system_docs.py`) сейчас зелёные — это пробел **точности прозы**, который машинно не ловится.
|
||||
|
||||
### P2 — Should fix
|
||||
- [ ] **Мусорные хвостовые теги `</content>` / `</invoke>` в 4 golden-source доках этого PR.**
|
||||
Литерально закоммичены в конце файлов: `docs/work-items/ORCH-124/06-adr/ADR-001-serial-gate-pause-without-blocking.md`
|
||||
(стр. 299–300), `docs/architecture/adr/adr-0051-serial-gate-pause-without-blocking.md` (стр. 111),
|
||||
`docs/work-items/ORCH-124/08-data-requirements.md` (стр. 54), `docs/work-items/ORCH-124/10-tech-risks.md`
|
||||
(стр. 41) — протёкшие в контент закрывающие XML-теги tool-call'а архитектора. Frontmatter цел, машинный
|
||||
парсинг не страдает, но golden-source доки не должны нести этот мусор. (Системный pre-existing паттерн —
|
||||
52 файла по репо, в т.ч. в уже смерженном ORCH-114 → отдельная гигиеническая зачистка уместна; но 4
|
||||
файла **этого** PR следует почистить здесь.)
|
||||
|
||||
### P3 — Nice-to-have (не блокирует)
|
||||
- [ ] HTTP-уровневых тестов для `POST /serial-gate/pause|resume` нет (терминальный no-op / под-флаг-off
|
||||
no-op / success+telegram). Не блокирует: тонкая обёртка над полностью покрытыми `db.set/clear/is_task_paused`,
|
||||
а SQL-предикат гейта протестирован исчерпывающе; соответствует конвенции репо (соседние операторские
|
||||
эндпоинты `/serial-gate/unfreeze`, `/transition-lease/release` тоже без HTTP-тестов).
|
||||
- [ ] `.task-dev.md` (dev-скретч) закоммичен с метаданными ORCH-124 — pre-existing трекаемый паттерн,
|
||||
вне области ORCH-124; кандидат на `.gitignore` отдельной задачей.
|
||||
|
||||
## Документация
|
||||
`src/` изменён (`config`/`db`/`main`/`serial_gate`). Golden-source **инженерные** доки (`README.md`
|
||||
архитектуры, `internals.md`, `CHANGELOG.md`, `.env.example`), оба ADR (work-item + сквозной `adr-0051`)
|
||||
и `08-data-requirements.md` обновлены синхронно и качественно. **Однако обзорная витрина `docs/overview/`
|
||||
(`tech-pipeline.md`, обязательно; `tech-data-model.md`/`tech-observability.md`, рекомендуется) НЕ
|
||||
обновлена**, хотя ORCH-124 меняет описанный в ней маршрут задач serial-gate — это P1 (ORCH-011/ORCH-079).
|
||||
Документация = golden source наравне с кодом; до устранения P1 — `REQUEST_CHANGES`. После добавления
|
||||
оси «пауза» в витрину (и зачистки P2-тегов) ядро фикса готово к приёмке.
|
||||
46
docs/work-items/ORCH-124/13-test-report.md
Normal file
46
docs/work-items/ORCH-124/13-test-report.md
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
result: FAIL
|
||||
work_item: ORCH-124
|
||||
stage: testing
|
||||
author_agent: test-runner
|
||||
status: failed
|
||||
created_at: 2026-06-16
|
||||
model_used: n/a
|
||||
exit_code: 1
|
||||
smoke: skipped
|
||||
---
|
||||
|
||||
# Test Gate Log (deterministic runner, ORCH-116)
|
||||
|
||||
pytest exit-code `1` -> `result: FAIL` (smoke: skipped).
|
||||
|
||||
Вердикт зафиксирован детерминированным test-раннером (ORCH-116), не LLM. PASS/FAIL = exit-код `pytest` + read-only smoke (`/health`, `/status`, `/queue` + блок `serial_gate`).
|
||||
|
||||
pytest stdout (tail):
|
||||
```
|
||||
onkeypatch.MonkeyPatch object at 0x7f3dd2a3bd70>
|
||||
|
||||
def test_r2_held_deploy_staging_not_rolled_back(monkeypatch):
|
||||
tid = _make_task("deploy-staging")
|
||||
# No 15-staging-log.md was written (infra-HOLD) -> check_staging_status is red.
|
||||
advance = MagicMock()
|
||||
monkeypatch.setattr(stage_engine, "advance_stage", advance)
|
||||
|
||||
result = stage_engine.advance_if_gate_passed(
|
||||
tid, "deploy-staging", "orchestrator", "ORCH-123", "feature/ORCH-123-x"
|
||||
)
|
||||
|
||||
> assert result is None # red gate -> stay, no advance call
|
||||
E AssertionError: assert <MagicMock name='mock()' id='139903524445248'> is None
|
||||
|
||||
tests/test_orch123_staging_runner_exec.py:462: AssertionError
|
||||
=============================== warnings summary ===============================
|
||||
src/config.py:8
|
||||
/repos/_wt/orchestrator/feature_ORCH-124-bug-serial-gate-treats-backlog/src/config.py:8: PydanticDeprecatedSince20: Support for class-based `config` is deprecated, use ConfigDict instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.13/migration/
|
||||
class Settings(BaseSettings):
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
=========================== short test summary info ============================
|
||||
FAILED tests/test_orch123_staging_runner_exec.py::test_r2_held_deploy_staging_not_rolled_back
|
||||
1 failed, 2177 passed, 1 warning in 100.82s (0:01:40)
|
||||
```
|
||||
@@ -1001,9 +1001,22 @@ class Settings(BaseSettings):
|
||||
# layer (env ORCH_SERIAL_GATE_FREEZE_ENABLED). False
|
||||
# -> freeze is neither set (post-deploy DEGRADED) nor
|
||||
# consulted in the claim gate.
|
||||
# serial_gate_pause_enabled -> ORCH-124 (adr-0051 D6): independent tumbler for
|
||||
# the per-task "park" axis (env
|
||||
# ORCH_SERIAL_GATE_PAUSE_ENABLED). True (default) ->
|
||||
# a task with tasks.paused_at NOT NULL is excluded
|
||||
# from the serial-gate "active task" predicate so an
|
||||
# URGENT successor may overtake a paused predecessor.
|
||||
# Default is a TRUE no-op until an operator pauses a
|
||||
# task (paused_at is NULL for all rows). False ->
|
||||
# pause-term omitted, serial-gate is byte-for-byte
|
||||
# ORCH-088/090 (deliberate rollback). Scope reuses
|
||||
# serial_gate_repos (no new *_repos flag); subordinate
|
||||
# to the serial_gate_enabled kill-switch.
|
||||
serial_gate_enabled: bool = True
|
||||
serial_gate_repos: str = ""
|
||||
serial_gate_freeze_enabled: bool = True
|
||||
serial_gate_pause_enabled: bool = True
|
||||
|
||||
# ORCH-090: STOP-status task cancellation (stop active agent + full progress
|
||||
# reset) and the relaunch-hole close. A new logical Plane key `stop` (fail-closed,
|
||||
|
||||
100
src/db.py
100
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
78
src/main.py
78
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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -50,6 +50,13 @@ def fresh_db(monkeypatch, tmp_path):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
monkeypatch.setattr("src.git_worktree.settings.worktrees_dir", str(tmp_path), raising=False)
|
||||
# Isolate repos_dir too: check_staging_status falls back to <repos_dir>/<repo>
|
||||
# (and origin/main on it) when the feature worktree is absent. Without this the
|
||||
# gate would read REAL on-disk artifacts from the shared clone (e.g. a merged
|
||||
# ORCH-123/15-staging-log.md), turning the intended-RED gate in test_r2 green and
|
||||
# making the suite order-dependent. Point it at an empty tmp subdir (no .git, no
|
||||
# work-items) so the staging gate is deterministically "not found" -> red.
|
||||
monkeypatch.setattr(cfg.settings, "repos_dir", str(tmp_path / "repos"), raising=False)
|
||||
# Runner ON, self-hosting scope, host-side strategy ON (defaults).
|
||||
monkeypatch.setattr(cfg.settings, "staging_runner_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "staging_runner_repos", "", raising=False)
|
||||
|
||||
353
tests/test_orch124_serial_gate_pause.py
Normal file
353
tests/test_orch124_serial_gate_pause.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""ORCH-124 — serial-gate wait/pause semantics (real tmp SQLite, no network).
|
||||
|
||||
A paused predecessor must NOT block an urgent successor's analyst-job, while a
|
||||
normally-executing predecessor still holds the FIFO gate (anti-stale-base ORCH-088
|
||||
preserved). Covers 04-test-plan.yaml TC-01…TC-15. The behaviour (not the exact SQL)
|
||||
is asserted: pause is an explicit, durable, DB-resolvable per-task signal
|
||||
(``tasks.paused_at``) that the offline hot-claim SQL reads locally.
|
||||
|
||||
TC-01 REGRESS (mandatory): earlier PAUSED task A + later urgent B -> claim picks
|
||||
B's analyst-job (gate open). Reproduces incident ORCH-116/ORCH-123.
|
||||
TC-02 Predecessor parked (Backlog intent) -> build_claim_clause does NOT block B.
|
||||
TC-03 Predecessor parked at another wait-stage (Needs-Input intent) -> still open.
|
||||
TC-04 ANTI-REGRESS ORCH-088: a NON-paused unfinished predecessor STILL blocks B.
|
||||
TC-05 Pause needs explicit durable intent; unpaused non-terminal task stays active.
|
||||
TC-06 Durable: the pause signal survives a connection/restart (read from the DB).
|
||||
TC-07 Resume restores participation in the gate (no eternal bypass).
|
||||
TC-08 Explicit blocks kept: an active repo_freeze still gates B (pause != bypass).
|
||||
TC-09 Explicit blocks kept: an unfinished declared dependency still gates B.
|
||||
TC-10 /queue snapshot: paused task not shown as active_task; reason is correct.
|
||||
TC-11 Three points agree on "active" (anti-drift): clause / mirror / snapshot.
|
||||
TC-12 Hot-path offline: claim resolves pause with no network (Plane not consulted).
|
||||
TC-13 never-raise / fail-directions: pause error -> build_claim_clause fail-OPEN.
|
||||
TC-14 Kill-switch: pause sub-flag off -> byte-for-byte ORCH-088/090 (paused blocks).
|
||||
TC-15 Structural anti-regress: STAGE_TRANSITIONS / QG_CHECKS / table schemas intact.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch124_pause.db")
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import src.db as db # noqa: E402
|
||||
from src.db import init_db, get_db, enqueue_job, claim_next_job # noqa: E402
|
||||
from src import serial_gate # noqa: E402
|
||||
from src import config as cfg # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "pause.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
# Serial gate ON; freeze layer ON; pause layer ON; empty CSV (all repos).
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_repos", "", raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_freeze_enabled", True, raising=False)
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_pause_enabled", True, raising=False)
|
||||
# Keep the unrelated dep-gate inert unless a test opts in.
|
||||
monkeypatch.setattr(cfg.settings, "task_deps_enabled", False, raising=False)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
def _make_task(work_item_id, stage="analysis", repo="orchestrator"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(work_item_id, work_item_id, repo, f"feature/{work_item_id}", stage, work_item_id),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-01
|
||||
def test_paused_predecessor_does_not_block_urgent_successor():
|
||||
"""REGRESS (ORCH-116/ORCH-123): earlier PAUSED A must not gate urgent B."""
|
||||
a = _make_task("ORCH-116", stage="development") # earlier predecessor
|
||||
b = _make_task("ORCH-123", stage="analysis") # later urgent task
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
|
||||
# Before the pause A holds the FIFO gate -> B is blocked (the incident state).
|
||||
assert claim_next_job() is None, "active A gates B (pre-pause, FIFO ORCH-088)"
|
||||
|
||||
# Operator parks A. Now B's analyst-job must become claimable.
|
||||
assert db.set_task_paused(a) is True
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b, (
|
||||
"a PAUSED predecessor must not gate the urgent successor (AC-1)"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-02
|
||||
def test_parked_backlog_predecessor_not_active_in_clause():
|
||||
a = _make_task("ORCH-201", stage="analysis") # "Backlog" intent
|
||||
b = _make_task("ORCH-202", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
db.set_task_paused(a)
|
||||
assert "paused_at IS NULL" in serial_gate.build_claim_clause()
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-03
|
||||
def test_parked_needs_input_predecessor_not_active():
|
||||
# Another wait-stage (review ~ "Needs-Input" intent) — same park column.
|
||||
a = _make_task("ORCH-203", stage="review")
|
||||
b = _make_task("ORCH-204", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
db.set_task_paused(a)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-04
|
||||
def test_non_paused_predecessor_still_blocks_fifo():
|
||||
"""ANTI-REGRESS ORCH-088: a normally-executing A still gates B."""
|
||||
_make_task("ORCH-210", stage="development") # NOT paused
|
||||
b = _make_task("ORCH-211", stage="analysis")
|
||||
enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
assert claim_next_job() is None, (
|
||||
"a non-paused unfinished predecessor must STILL hold the gate (FIFO intact)"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-05
|
||||
def test_pause_requires_explicit_durable_intent():
|
||||
a = _make_task("ORCH-215", stage="development")
|
||||
b = _make_task("ORCH-216", stage="analysis")
|
||||
enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
# No explicit pause -> A is active -> gate held (no heuristic auto-pause).
|
||||
assert db.is_task_paused(a) is False
|
||||
assert claim_next_job() is None
|
||||
# The pause signal is DB-resolvable once set explicitly.
|
||||
db.set_task_paused(a)
|
||||
assert db.is_task_paused(a) is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-06
|
||||
def test_pause_signal_is_durable_across_restart():
|
||||
a = _make_task("ORCH-220", stage="development")
|
||||
b = _make_task("ORCH-221", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
db.set_task_paused(a)
|
||||
# Simulate a restart: drop in-memory state, re-run the idempotent migration.
|
||||
init_db()
|
||||
assert db.is_task_paused(a) is True, "pause must survive restart (read from DB)"
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-07
|
||||
def test_resume_restores_gate_participation():
|
||||
a = _make_task("ORCH-225", stage="development")
|
||||
b = _make_task("ORCH-226", stage="analysis")
|
||||
enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
db.set_task_paused(a)
|
||||
assert claim_next_job() is not None # B claimable while A paused
|
||||
# Re-queue a fresh analyst-job for B (the previous one was claimed) and resume A.
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE jobs SET status='queued', started_at=NULL WHERE task_id=?", (b,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
assert db.clear_task_paused(a) is True
|
||||
assert db.is_task_paused(a) is False
|
||||
assert claim_next_job() is None, (
|
||||
"after resume A holds the gate again — no eternal bypass (AC-10)"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-08
|
||||
def test_pause_does_not_bypass_freeze():
|
||||
_make_task("ORCH-230", stage="done") # nothing unfinished
|
||||
a = _make_task("ORCH-231", stage="development")
|
||||
b = _make_task("ORCH-232", stage="analysis")
|
||||
enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
db.set_task_paused(a)
|
||||
# Freeze the repo: even with A paused, B must stay blocked by the freeze.
|
||||
serial_gate.set_repo_freeze("orchestrator", "DEGRADED", "ORCH-230")
|
||||
assert claim_next_job() is None, "an active freeze gates B; pause must not bypass it"
|
||||
# Clearing the freeze (A still paused) -> B becomes claimable.
|
||||
serial_gate.clear_repo_freeze("orchestrator")
|
||||
assert claim_next_job() is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-09
|
||||
def test_pause_does_not_bypass_declared_dependency(monkeypatch):
|
||||
monkeypatch.setattr(cfg.settings, "task_deps_enabled", True, raising=False)
|
||||
a = _make_task("ORCH-240", stage="development")
|
||||
b = _make_task("ORCH-241", stage="analysis")
|
||||
enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
assert db.add_dependency(b, a) is True # B blocked-by A
|
||||
db.set_task_paused(a)
|
||||
# task_deps reads the {done,cancelled} terminal only (NOT paused_at): an
|
||||
# unfinished declared dependency keeps B blocked even though A is paused.
|
||||
assert claim_next_job() is None, (
|
||||
"a declared unfinished dependency gates B; pause must not bypass it (AC-5)"
|
||||
)
|
||||
# Once A is terminal the dependency is satisfied -> B is claimable.
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE tasks SET stage='done' WHERE id=?", (a,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
assert claim_next_job() is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-10
|
||||
def test_snapshot_reason_and_paused_list():
|
||||
a = _make_task("ORCH-250", stage="development")
|
||||
b = _make_task("ORCH-251", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
|
||||
# (a) A active (not paused) -> B waits with reason 'active-task'; A is active_task.
|
||||
per = serial_gate.snapshot()["per_repo"]["orchestrator"]
|
||||
assert per["active_task"]["work_item_id"] == "ORCH-250"
|
||||
assert per["paused"] == []
|
||||
wb = next(w for w in per["waiting"] if w["job_id"] == job_b)
|
||||
assert wb["reason"] == "active-task"
|
||||
# Existing keys preserved (BC).
|
||||
assert set(per) >= {"active_task", "waiting", "frozen", "frozen_reason", "frozen_at"}
|
||||
|
||||
# (b) Pause A -> A no longer active_task; it appears in `paused`; B is claimable
|
||||
# (reason None — a paused predecessor is by design NOT a wait reason).
|
||||
db.set_task_paused(a)
|
||||
per = serial_gate.snapshot()["per_repo"]["orchestrator"]
|
||||
assert per["active_task"] is None or per["active_task"]["work_item_id"] != "ORCH-250"
|
||||
assert any(p["work_item_id"] == "ORCH-250" for p in per["paused"])
|
||||
wb = next(w for w in per["waiting"] if w["job_id"] == job_b)
|
||||
assert wb["reason"] is None
|
||||
|
||||
# (c) Freeze -> reason 'freeze' (highest priority).
|
||||
serial_gate.set_repo_freeze("orchestrator", "DEGRADED", "ORCH-250")
|
||||
per = serial_gate.snapshot()["per_repo"]["orchestrator"]
|
||||
wb = next(w for w in per["waiting"] if w["job_id"] == job_b)
|
||||
assert wb["reason"] == "freeze"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-11
|
||||
def test_three_points_agree_on_active():
|
||||
"""Anti-drift: clause / mirror / snapshot classify predecessor A identically.
|
||||
|
||||
B is excluded from the mirror (``exclude_task_id=b``) to mirror the clause's
|
||||
own-row exclusion (``t2.id < jobs.task_id``), so the three points are asked the
|
||||
SAME question: "does the non-B predecessor A count as an active blocker?".
|
||||
"""
|
||||
a = _make_task("ORCH-260", stage="development")
|
||||
b = _make_task("ORCH-261", stage="analysis")
|
||||
enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
|
||||
# A NOT paused -> all three say A is active.
|
||||
assert serial_gate.repo_has_active_task("orchestrator", exclude_task_id=b) is True
|
||||
assert (serial_gate.snapshot()["per_repo"]["orchestrator"]["active_task"]
|
||||
["work_item_id"] == "ORCH-260")
|
||||
assert claim_next_job() is None # clause blocks B on A
|
||||
|
||||
# A paused -> all three agree A is NOT active (consistent, no drift).
|
||||
db.set_task_paused(a)
|
||||
assert serial_gate.repo_has_active_task("orchestrator", exclude_task_id=b) is False
|
||||
snap = serial_gate.snapshot()["per_repo"]["orchestrator"]
|
||||
active = snap["active_task"]
|
||||
assert active is None or active["work_item_id"] != "ORCH-260"
|
||||
assert any(p["work_item_id"] == "ORCH-260" for p in snap["paused"])
|
||||
assert claim_next_job() is not None # clause now opens for B
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-12
|
||||
def test_hot_path_is_offline():
|
||||
"""The pause predicate resolves from the local DB only — no network."""
|
||||
a = _make_task("ORCH-270", stage="development")
|
||||
b = _make_task("ORCH-271", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
db.set_task_paused(a)
|
||||
# Functional: claim works with no Plane configured/reachable.
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b
|
||||
# Structural: the gate leaf imports no network client (offline hot path, NFR-2).
|
||||
import inspect
|
||||
src = inspect.getsource(serial_gate)
|
||||
for forbidden in ("import httpx", "import requests", "plane_sync", "urllib.request"):
|
||||
assert forbidden not in src, f"serial_gate must stay offline (found {forbidden!r})"
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-13
|
||||
def test_pause_error_fails_open_and_never_raises(monkeypatch):
|
||||
_make_task("ORCH-280", stage="development") # would close the gate
|
||||
b = _make_task("ORCH-281", stage="analysis")
|
||||
job_b = enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError("pause layer probe down")
|
||||
|
||||
monkeypatch.setattr(serial_gate, "_pause_layer_enabled", _boom, raising=True)
|
||||
# build_claim_clause must fail-OPEN ('' fragment) — never raise, never wedge.
|
||||
assert serial_gate.build_claim_clause() == ""
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_b, (
|
||||
"a pause-layer error must fail-OPEN, not wedge the queue (AC-9)"
|
||||
)
|
||||
# The other public functions degrade conservatively without raising.
|
||||
assert serial_gate.repo_has_active_task("orchestrator") in (True, False)
|
||||
assert isinstance(serial_gate.snapshot(), dict)
|
||||
# Freeze direction is NOT inverted by a pause error (still fail-CLOSED on doubt).
|
||||
monkeypatch.setattr(
|
||||
serial_gate, "_active_freeze_row",
|
||||
lambda repo: (_ for _ in ()).throw(RuntimeError("freeze read down")),
|
||||
raising=True,
|
||||
)
|
||||
assert serial_gate.is_repo_frozen("orchestrator") is True
|
||||
# The DB mutators/readers never raise on bad input either.
|
||||
assert db.set_task_paused(None) is False
|
||||
assert db.clear_task_paused(None) is False
|
||||
assert db.is_task_paused(None) is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-14
|
||||
def test_kill_switch_off_is_byte_for_byte_orch088(monkeypatch):
|
||||
monkeypatch.setattr(cfg.settings, "serial_gate_pause_enabled", False, raising=False)
|
||||
a = _make_task("ORCH-290", stage="development")
|
||||
b = _make_task("ORCH-291", stage="analysis")
|
||||
enqueue_job("analyst", "orchestrator", "B", task_id=b)
|
||||
db.set_task_paused(a)
|
||||
# Pause sub-flag OFF -> the pause-term is omitted -> a paused task STILL counts
|
||||
# as active (deliberate ORCH-088/090 rollback behaviour).
|
||||
assert "paused_at" not in serial_gate.build_claim_clause()
|
||||
assert claim_next_job() is None, (
|
||||
"with the pause sub-flag off serial-gate is byte-for-byte ORCH-088/090"
|
||||
)
|
||||
# Outside the (empty) repo scope nothing changes for enduro either.
|
||||
et = _make_task("ET-290", stage="analysis", repo="enduro-trails")
|
||||
job_et = enqueue_job("analyst", "enduro-trails", "B", task_id=et)
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == job_et
|
||||
|
||||
|
||||
# --------------------------------------------------------------- TC-15
|
||||
def test_registries_and_schemas_unchanged():
|
||||
from src.stages import STAGE_TRANSITIONS
|
||||
from src.qg.checks import QG_CHECKS
|
||||
# ORCH-124 is a scheduler-only change: no new edge, no new terminal sink.
|
||||
assert set(STAGE_TRANSITIONS) == {
|
||||
"created", "analysis", "architecture", "development", "review",
|
||||
"testing", "deploy-staging", "deploy", "done", "cancelled",
|
||||
}
|
||||
# No serial-gate / pause QG check was introduced (the gate is a scheduler cond).
|
||||
assert not any("serial" in k or "pause" in k for k in QG_CHECKS)
|
||||
# Existing table schemas intact; tasks gained the additive paused_at column.
|
||||
conn = get_db()
|
||||
try:
|
||||
task_cols = {r[1] for r in conn.execute("PRAGMA table_info(tasks)").fetchall()}
|
||||
job_cols = {r[1] for r in conn.execute("PRAGMA table_info(jobs)").fetchall()}
|
||||
dep_cols = {r[1] for r in conn.execute("PRAGMA table_info(job_deps)").fetchall()}
|
||||
frz_cols = {r[1] for r in conn.execute("PRAGMA table_info(repo_freeze)").fetchall()}
|
||||
finally:
|
||||
conn.close()
|
||||
assert "paused_at" in task_cols # additive
|
||||
assert {"id", "repo", "stage", "work_item_id"}.issubset(task_cols)
|
||||
assert {"id", "agent", "repo", "status", "task_id"}.issubset(job_cols)
|
||||
assert {"task_id", "depends_on_task_id"}.issubset(dep_cols)
|
||||
assert {"repo", "frozen_at", "cleared_at"}.issubset(frz_cols)
|
||||
Reference in New Issue
Block a user