From 105d6e9cbafb217bb19dfaf9bb14a59e34d576c9 Mon Sep 17 00:00:00 2001 From: Slava Date: Tue, 9 Jun 2026 19:59:15 +0300 Subject: [PATCH 1/9] docs: init ORCH-090 business request --- docs/work-items/ORCH-090/00-business-request.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/work-items/ORCH-090/00-business-request.md diff --git a/docs/work-items/ORCH-090/00-business-request.md b/docs/work-items/ORCH-090/00-business-request.md new file mode 100644 index 0000000..ac44074 --- /dev/null +++ b/docs/work-items/ORCH-090/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: Механизм отмены задачи: статус STOP в Plane (остановка + полный сброс) + +Work Item ID: ORCH-090 + +## Description + +TBD -- 2.49.1 From 96a99a09b7ac741365ce43619935b02e746fb2b3 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 20:06:31 +0300 Subject: [PATCH 2/9] analyst(ET): auto-commit from analyst run_id=496 --- docs/work-items/ORCH-090/01-brd.md | 165 +++++++++++++++ docs/work-items/ORCH-090/02-trz.md | 191 ++++++++++++++++++ .../ORCH-090/03-acceptance-criteria.md | 146 +++++++++++++ docs/work-items/ORCH-090/04-test-plan.yaml | 107 ++++++++++ 4 files changed, 609 insertions(+) create mode 100644 docs/work-items/ORCH-090/01-brd.md create mode 100644 docs/work-items/ORCH-090/02-trz.md create mode 100644 docs/work-items/ORCH-090/03-acceptance-criteria.md create mode 100644 docs/work-items/ORCH-090/04-test-plan.yaml diff --git a/docs/work-items/ORCH-090/01-brd.md b/docs/work-items/ORCH-090/01-brd.md new file mode 100644 index 0000000..22497f0 --- /dev/null +++ b/docs/work-items/ORCH-090/01-brd.md @@ -0,0 +1,165 @@ +--- +work_item: ORCH-090 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 01 — BRD (бизнес-требования): ORCH-090 — Механизм отмены задачи: статус STOP в Plane (остановка + полный сброс) + +Work Item: **ORCH-090** · Repo: **orchestrator** · Стадия: analysis + +## 1. Бизнес-контекст и проблема + +Сегодня в оркестраторе **нет штатного способа отменить/остановить задачу**. Оператор вынужден +выполнять разрозненные ручные действия: убить процесс агента, дождаться исчерпания ретраев job, +удалить ветку/worktree/строку task из БД и вручную сбросить статус в Plane. Это медленно, +ошибкоопасно и не воспроизводимо (инцидент 09.06 с ORCH-087 — оператор делал всё это руками). + +Вторая, связанная проблема — **дыра ручного релонча**: `src/webhooks/plane.py::handle_status_start` +при ручном переводе задачи в рабочий статус (через «To Analyse» / In Progress) **повторно ставит в +очередь агента текущей стадии на той же ветке** (`has_active_job_for_task` → иначе +`enqueue_job(stage_agent, …)`). Это означает, что попытка оператора «подтолкнуть» задачу сменой +статуса может незаметно релончить агента — именно этот механизм усугубил сегодняшний инцидент. + +Требуется единый, декларативный механизм: **перевод задачи в новый Plane-статус STOP → +оркестратор немедленно останавливает всю работу по задаче и полностью сбрасывает её прогресс**. +Повторный запуск возможен ТОЛЬКО через «To Analyse» (с нуля). Никакой другой статус пайплайн не +запускает. + +**Установленные факты (по текущему коду, не изобретать):** +- Машина стадий — `src/stages.py::STAGE_TRANSITIONS`; терминальная стадия только `done` + (`cancelled`-стадии нет). +- Plane-маппинг — `src/plane_sync.py`: `_PLANE_NAME_TO_KEY` уже содержит `"Cancelled" → "cancelled"`, + `_DEFAULT_STATES` содержит UUID `cancelled`; имени «STOP» в маппинге сейчас нет. +- Остановка процесса агента уже реализована как graceful-каскад в + `src/agents/launcher.py::_watchdog` (SIGTERM → grace `agent_kill_grace_seconds` → SIGKILL); PID + задачи хранится в `jobs.pid`. +- Статусы job в `jobs` — `queued | running | done | failed`; статуса `cancelled` нет. +- Терминал-скип для реконсилятора/мониторов уже учитывает `done` и `cancelled` + (`src/reconciler.py::_is_terminal_state`, ORCH-068/086). +- Запуск пайплайна с нуля — `handle_status_start → start_pipeline` (создаёт ветку + docs + analyst). + +## 2. Объём (scope) + +### В объёме +- Новый Plane-статус **STOP** как сигнал отмены задачи (распознавание в диспетчере статусов). +- **Остановка задачи (G1):** graceful-стоп активного агента (SIGTERM), отмена всех job'ов задачи + (queued/running → терминальный «cancelled»-исход), исчерпание ретраев (запрет авто-requeue), + снятие таймеров/мониторов (post-deploy monitor, brd-review clock и т.п.). +- **Полный сброс прогресса (G2):** удаление/архив рабочей ветки и worktree, очистка незавершённого + прогресса задачи в БД так, чтобы повторный старт шёл строго через `start_pipeline` (с нуля). + Docs-артефакты задачи — сохранить/забэкапить (не теряем аналитику). +- **Закрытие дыры релонча (G3/G4):** перевод в любой промежуточный рабочий статус + (Development/Architecture/Review/Deploying/…) вручную **не** запускает агента; единственный вход + к запуску пайплайна — «To Analyse» (старт с нуля). +- **Идемпотентность и fail-safe (G5):** STOP на уже остановленной/завершённой задаче — no-op; STOP + во время критичной операции (merge/deploy) — корректное прерывание без порчи `main`/прода. +- Kill-switch фичи; наблюдаемость (лог + Telegram + блок в `GET /queue`). +- Обновление документации (CLAUDE.md, architecture/README.md, CHANGELOG.md) и инфра-предусловие + (создать статус STOP на доске Plane). + +### Вне объёма +- Автоматическая отмена задач по таймауту/эвристике — STOP только по явному человеческому сигналу. +- Возобновление задачи «с середины» после STOP — сознательно НЕ поддерживается (только перезапуск + с нуля через To Analyse). +- Изменение семантики Rejected (откат на стадию назад) — STOP это отдельный путь, не Rejected. +- Изменение состава/семантики `STAGE_TRANSITIONS` exit-гейтов и `QG_CHECKS` / `check_*`. +- Откат уже задеплоенного в прод кода (rollback) — STOP не выполняет rollback; он лишь прерывает + незавершённую работу безопасно. +- Кросс-проектная отмена пакета задач (отменяется одна задача за сигнал). + +## 3. Заинтересованные стороны + +- **Заказчик / владелец продукта:** Слава (идея STOP-статуса). +- **Оператор оркестратора** (Стрим и др.) — главный потребитель: получает кнопку «отменить» вместо + ручной хирургии по БД/процессам. +- **Затрагиваемые проекты:** orchestrator (self-hosting) и enduro-trails (общая прод-БД/очередь) — + изменения должны быть аддитивны и не задевать enduro при выключенном/неприменимом флаге. +- **Принимает результат:** reviewer/tester по критериям приёмки (`03`/`04`). + +## 4. Бизнес-требования (BR) + +- **BR-1 (STOP останавливает работу)** — перевод задачи в Plane-статус STOP → оркестратор + останавливает всю работу по задаче: (a) активному агенту посылается SIGTERM (graceful, с + последующим жёстким kill по существующему grace-каскаду); (b) все job'ы задачи (queued и running) + переводятся в терминальный «отменённый» исход и не выбираются claim'ом; (c) ретраи исчерпываются + (никакого авто-requeue после STOP); (d) таймеры/мониторы задачи (post-deploy monitor, brd-review + clock, merge-lease defer и т.п.) снимаются. Контракт фичи — **never-raise**. +- **BR-2 (STOP = полный сброс)** — после STOP задача НЕ продолжается с середины. Рабочая + ветка+worktree удаляются/архивируются; незавершённый прогресс задачи в БД очищается или + помечается так, что повторный запуск идёт через `start_pipeline` с нуля (свежая ветка от + актуального `origin/main`, новый аналитик). Docs-артефакты (`01..17`) — сохранить/забэкапить. +- **BR-3 (единственный вход — To Analyse)** — единственный Plane-статус, запускающий пайплайн — + «To Analyse» (старт с нуля). После STOP повторный «To Analyse» создаёт задачу заново. +- **BR-4 (закрыть дыру релонча)** — ручной перевод задачи в любой промежуточный рабочий статус + (Architecture/Development/Review/Testing/Deploying/Awaiting Deploy/…) **не** запускает агента + соответствующей стадии. Текущее поведение `handle_status_start`, релончащее агента текущей стадии + на той же ветке, должно быть устранено/загейчено так, чтобы пайплайн стартовал только из + «To Analyse». +- **BR-5 (идемпотентность)** — STOP на задаче, которая уже остановлена (cancelled), уже `done` или + не существует, — **no-op** (без ошибок, без побочных эффектов, без повторного kill). +- **BR-6 (безопасное прерывание критичных операций)** — STOP во время merge/deploy не оставляет + `main` в half-merged состоянии и не роняет/не рестартит прод-контейнер. Если критичный шаг уже + необратимо запущен (детач-деплой/слияние в процессе), STOP не должен его «разорвать» с порчей — + допустимо дождаться/пропустить необратимый шаг и зафиксировать честный итог (детали безопасной + точки прерывания — архитектору). +- **BR-7 (STOP ≠ Rejected)** — STOP это полная остановка+сброс задачи, а не откат на предыдущую + стадию. Существующий путь Rejected (`handle_verdict(approved=False)` → `_rollback_stage`) не + меняется и не смешивается с STOP. +- **BR-8 (наблюдаемость)** — каждое срабатывание STOP прозрачно: лог, Telegram-уведомление (с + кликабельным номером задачи), Plane-коммент (best-effort), отражение в live-карточке и read-only + блок в `GET /queue`. + +## 5. Нефункциональные требования (NFR) + +- **NFR-1 (нулевая регрессия + kill-switch)** — фича под флагом включения (по образцу + `serial_gate_enabled`/`merge_gate_enabled`); при выключенном флаге поведение оркестратора строго + как сейчас. `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / семантика существующих статусов — без + изменений. +- **NFR-2 (общая прод-БД, аддитивность)** — любые изменения схемы БД — только аддитивные и + идемпотентные (`CREATE TABLE IF NOT EXISTS` / `_ensure_column`); enduro-trails не затрагивается. +- **NFR-3 (self-hosting safety)** — STOP не должен убить сам оркестратор / прод и не портить `main`. + Прерывание merge/deploy — fail-safe (не оставлять half-merge; не рестартить прод). +- **NFR-4 (restart-safe)** — состояние «задача отменена» durable (БД); после рестарта контейнера + отменённая задача не «оживает» и не релончится reconciler'ом/reaper'ом (переиспользовать + терминал-скип `done`/`cancelled`). +- **NFR-5 (never-raise)** — обработчик STOP и закрытие дыры релонча не должны валить вебхук-поток; + ошибка на единице работы логируется и не прерывает обработку других задач/проектов. +- **NFR-6 (offline-устойчивость горячего пути)** — закрытие дыры релонча и терминал-скип не должны + добавлять обязательных сетевых вызовов в горячий claim-цикл. + +## 6. Допущения и ограничения + +- На доске Plane проекта ORCH будет создан статус **STOP** (инфра-предусловие); до его создания + фича в режиме fail-safe (нет статуса → нет STOP-действия, ничего не ломается). +- Логический ключ `cancelled` и его UUID/группа уже присутствуют в `plane_sync` — STOP может + переиспользовать «cancelled»-семантику терминал-скипа (точное соответствие имя→ключ и + группа-`cancelled` — решение архитектора). +- Существующий graceful kill-каскад агента (`_watchdog`: SIGTERM→grace→SIGKILL) переиспользуется + для остановки активного агента; новый механизм kill не изобретается. +- Терминал-скип `done`/`cancelled` в `reconciler`/мониторах уже есть и должен покрыть + STOP-отменённые задачи (NFR-4) — переиспользовать, не дублировать. +- Архитектурные решения (хранилище статуса отмены, точка безопасного прерывания merge/deploy, + удаление vs архив ветки, точные точки врезки в `plane.py`) — зона архитектора (`06-adr/`). + +## 7. Критерии успеха + +STOP-статус, выставленный на задаче, приводит к: остановленному агенту, отменённым job'ам без +авто-requeue, снятым таймерам/мониторам, удалённой/заархивированной ветке+worktree, durable-статусу +«отменена» (переживает рестарт), сохранённым docs-артефактам. Ручной перевод в промежуточный +рабочий статус более не релончит агента; пайплайн стартует только из «To Analyse». STOP +идемпотентен и безопасен при merge/deploy. Детальные PASS/FAIL — в `03-acceptance-criteria.md`. + +## 8. Риски + +- Гонка «STOP во время merge/deploy» → риск half-merge/порчи `main` (mitigation: безопасная точка + прерывания, fail-safe — детали архитектору). +- Закрытие дыры релонча может задеть легитимный сценарий resume после «Needs Input» → нужно + сохранить намеренные сценарии возврата к работе, не ломая их (уточнить с архитектором, какой путь + заменяет релонч). +- Очистка прогресса в БД при общей прод-БД → риск задеть enduro/другие задачи (mitigation: + строго per-task, аддитивно). +- Детали — `10-tech-risks.md` (заполняет архитектор). diff --git a/docs/work-items/ORCH-090/02-trz.md b/docs/work-items/ORCH-090/02-trz.md new file mode 100644 index 0000000..b36f1b0 --- /dev/null +++ b/docs/work-items/ORCH-090/02-trz.md @@ -0,0 +1,191 @@ +--- +work_item: ORCH-090 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 02 — ТЗ (TRZ): ORCH-090 — Механизм отмены задачи: статус STOP в Plane (остановка + полный сброс) + +Work Item: **ORCH-090** · Repo: **orchestrator** · Стадия: analysis + +> ТЗ описывает **что** и **где** должно измениться (модули/контракты/артефакты), выведенное из BRD +> и фактического кода. **Как** (хранилище статуса отмены, точка безопасного прерывания merge/deploy, +> удаление vs архив ветки, точные точки врезки) — решает архитектор в `06-adr/`. ТЗ фиксирует +> требования и границы, не предлагает архитектурное решение. + +--- + +## 1. Сводка изменения + +Ввести обработку нового Plane-статуса **STOP** как сигнала отмены задачи. При его получении +оркестратор: (1) останавливает активного агента (graceful SIGTERM через существующий каскад), +(2) отменяет все job'ы задачи и исчерпывает ретраи, (3) снимает таймеры/мониторы, (4) удаляет/ +архивирует рабочую ветку+worktree и сбрасывает незавершённый прогресс в БД до состояния «отменена» +(durable), сохраняя docs-артефакты. Параллельно закрывается **дыра релонча**: ручной перевод в +промежуточный рабочий статус больше не запускает агента — единственный вход к запуску пайплайна +остаётся «To Analyse» (`start_pipeline`). Всё — аддитивно, под kill-switch, never-raise, +restart-safe. `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*` и семантика существующих статусов — +**не меняются**. + +--- + +## 2. Задействованные модули / пути + +| Путь | Действие | +|------|----------| +| `src/webhooks/plane.py` | изменить: добавить распознавание/маршрутизацию STOP (`handle_issue_updated`) → новый обработчик `handle_stop` (имя — на усмотрение архитектора); **загейтить/убрать релонч агента** в `handle_status_start` (промежуточные статусы не запускают агента; пайплайн — только из `To Analyse`/`start_pipeline`) | +| `src/agents/launcher.py` | изменить: предоставить/переиспользовать остановку активного процесса задачи (SIGTERM каскад `_watchdog`; `jobs.pid`), пометку «не релончить» (исчерпание `max_attempts`/запрет авто-requeue для отменённой задачи) | +| `src/queue_worker.py` / `src/db.py` | изменить: отмена job'ов задачи (queued/running → терминальный «cancelled»-исход); claim не выбирает отменённые; helper'ы выборки job'ов задачи; (возможно) новый терминальный статус job `cancelled` ИЛИ переиспользование `failed`+флаг — выбор архитектора; durable-пометка задачи «отменена» в `tasks` | +| `src/git_worktree.py` | изменить/переиспользовать: удаление/архив рабочей ветки и worktree отменённой задачи (`remove_worktree`; удаление/архив Gitea-ветки) — never-raise | +| `src/plane_sync.py` | изменить: маппинг Plane-статуса STOP (`_PLANE_NAME_TO_KEY` / `_DEFAULT_STATES`); переиспользовать группу `cancelled` для терминал-скипа; сеттер статуса (best-effort) | +| `src/stages.py` | при необходимости — терминальная трактовка отменённой задачи (НЕ менять exit-гейты рёбер; добавление `cancelled`-стадии — решение архитектора, см. §5) | +| `src/reconciler.py` | переиспользовать терминал-скип `done`/`cancelled` (`_is_terminal_state`) — отменённая задача не реконсилируется/не релончится | +| `src/job_reaper.py` | согласовать: reaper не «оживляет» отменённые job'ы (терминальный исход не requeue'ится) | +| `src/stage_engine.py` | согласовать: снятие таймеров/мониторов (post-deploy monitor, brd-review clock) и безопасное прерывание merge/deploy при STOP | +| `src/notifications.py` | переиспользовать `send_telegram`/`update_task_tracker` для алерта/карточки отмены (never-raise, кликабельный номер) | +| `src/config.py` | изменить: новый kill-switch `stop_status_enabled` (+ при необходимости область репо/доп-флаги) по образцу `serial_gate_enabled` | +| `src/main.py` | изменить: read-only блок наблюдаемости отмены в `GET /queue` (аддитивно) | +| `docs/architecture/README.md`, `CLAUDE.md`, `CHANGELOG.md` | обновить в том же PR (golden source) | +| `tests/` | добавить тесты (см. `04-test-plan.yaml`) | + +> Чистую логику распознавания/решения по STOP желательно вынести в leaf-модуль (по образцу +> `src/serial_gate.py` / `src/labels.py`, never-raise) — окончательно решает архитектор. + +--- + +## 3. Функциональные требования + +### FR-1 — Распознавание и маршрутизация STOP (BR-1, BR-5) +- `handle_issue_updated` (`webhooks/plane.py`) распознаёт перевод задачи в логический статус STOP + (через `_PLANE_NAME_TO_KEY`/группа `cancelled`) и маршрутизирует в обработчик отмены. +- Обработчик идемпотентен: если задача уже отменена / `done` / отсутствует → no-op (BR-5). +- Контракт — never-raise: ошибка обработки STOP логируется, вебхук-поток не падает (NFR-5). + +### FR-2 — Остановка активного агента (BR-1a) +- Для running-job'а задачи послать активному процессу SIGTERM (graceful) через существующий + каскад `launcher._watchdog` (SIGTERM → grace `agent_kill_grace_seconds` → SIGKILL); PID берётся + из `jobs.pid`. +- Если активного процесса нет (idle/queued) — шаг no-op. + +### FR-3 — Отмена job'ов и исчерпание ретраев (BR-1b, BR-1c) +- Все job'ы задачи (`status IN (queued, running)`) переводятся в **терминальный отменённый исход** + так, что `claim_next_job` их больше не выбирает и `_finalize_*`/reaper не делает авто-requeue. +- Запрет авто-requeue: после STOP `attempts` считаются исчерпанными (либо отдельный терминальный + статус job `cancelled`, либо `failed`+маркер — выбор архитектора). Reaper (`job_reaper.py`) и + `_finalize_permanent` не должны возвращать отменённый job в `queued`. + +### FR-4 — Снятие таймеров и мониторов (BR-1d) +- При STOP снимаются/обнуляются связанные с задачей таймеры и фоновые наблюдатели: post-deploy + monitor (ORCH-021), brd-review clock (ORCH-087), отложенные defer'ы merge-lease/serial-gate. +- Терминал-скип `done`/`cancelled` (`reconciler._is_terminal_state`, ORCH-068/086) применяется к + отменённой задаче, чтобы реконсилятор/мониторы её не трогали (NFR-4). + +### FR-5 — Полный сброс прогресса (BR-2) +- Рабочая ветка и worktree задачи удаляются/архивируются (`git_worktree.remove_worktree` + удаление/ + архив Gitea-ветки; never-raise). `main` не трогается, force-push в `main` запрещён. +- Незавершённый прогресс задачи в БД приводится к durable-состоянию «отменена» так, что повторный + запуск возможен ТОЛЬКО через `start_pipeline` с нуля (новая ветка от свежего `origin/main`, новый + analyst). Конкретика «очистить строку vs пометить cancelled» — архитектору; инвариант: + возобновления «с середины» не происходит. +- **Docs-артефакты задачи (`01..17`) сохраняются/бэкапятся** — не удаляются вместе с прогрессом. + +### FR-6 — Закрытие дыры релонча (BR-3, BR-4) +- `handle_status_start` (или эквивалентная точка) **не должен релончить агента текущей стадии** при + ручном переводе в промежуточный рабочий статус (Architecture/Development/Review/Testing/ + Deploying/Awaiting Deploy/Monitoring/…). +- Запуск пайплайна остаётся возможен **только** через статус «To Analyse» → `start_pipeline` + (создание ветки + docs + enqueue analyst). Любой намеренный сценарий «вернуть задачу в работу» + (например, после Needs Input) должен быть пересмотрен так, чтобы НЕ опираться на авто-релонч + агента сменой рабочего статуса (точный заменяющий механизм — архитектору). + +### FR-7 — Безопасное прерывание критичных операций (BR-6, NFR-3) +- STOP во время merge/deploy не оставляет `main` в half-merged состоянии и не рестартит/не роняет + прод-контейнер. Если необратимый шаг (detached self-deploy / слияние PR) уже запущен — STOP не + «разрывает» его с порчей: допускается дать необратимому шагу завершиться/зафиксировать честный + исход, после чего применить отмену. Точка безопасного прерывания и обработка merge-lease — ADR. + +### FR-8 — Наблюдаемость (BR-8) +- Каждое срабатывание STOP: `logger.info/warning` (что остановлено/сброшено), Telegram-алерт + (`send_telegram`, кликабельный номер `plane_issue_link`), Plane-коммент (best-effort), обновление + live-карточки (`update_task_tracker`, never-raise), read-only блок отмены в `GET /queue`. + +--- + +## 4. Изменения API + +- **Новых обязательных публичных endpoint'ов нет.** Триггер STOP — смена статуса Plane (webhook), + не REST. (По аналогии с ORCH-088 возможен опциональный админ-эндпоинт принудительной отмены — + на усмотрение архитектора; если вводится, описать в ADR и таблице API README.) +- `GET /queue` — **аддитивно**: новый read-only блок (например `stop`/`cancel`) — флаг `enabled`, + счётчик отменённых задач/job'ов, последние отмены. Существующие ключи не меняются; never-raise. +- Внешний контракт вебхука `POST /webhook/plane` — не меняется (новая ветка обработки статуса + внутри `handle_issue_updated`). + +--- + +## 5. Изменения схемы БД + +> Только **аддитивные, идемпотентные** миграции (общая прод-БД; enduro не трогать). +> `CREATE TABLE IF NOT EXISTS` / `_ensure_column`. + +- **Статус job «отменён» (FR-3):** требуется терминальный исход, который не requeue'ится. Варианты + (выбор — архитектор): новый статус `jobs.status='cancelled'` ИЛИ переиспользование `failed` + + аддитивный маркер. Требование к выбранному варианту: claim/finalize/reaper не возвращают его в + `queued`; restart-safe. +- **Состояние задачи «отменена» (FR-5, NFR-4):** durable-признак, что задача отменена и не + возобновляется с середины. Варианты: добавление терминальной стадии `cancelled` в `tasks.stage` + (учитывается терминал-скипом `done`/`cancelled`, уже поддержан reconciler'ом) ИЛИ аддитивная + колонка/таблица. `STAGE_TRANSITIONS` (exit-гейты рёбер) при этом **не меняются** — отмена это + терминальное состояние, не новое ребро конвейера. +- `QG_CHECKS`, `check_*`, `job_deps`, `agent_runs`-контракт, `repo_freeze` — **без изменений**. + +--- + +## 6. Требования к новым/изменённым QG checks + +- **Новых QG-проверок не вводить.** STOP — это решение диспетчера статусов/планировщика (отмена), + а не Quality Gate стадии. Реестр `QG_CHECKS` и `check_*` не меняются (по образцу `task_deps` + ORCH-026 и `serial_gate` ORCH-088 — логика в обработчике/claim, не новый QG). + +--- + +## 7. Совместимость / регресс + +- **Kill-switch:** новый флаг `stop_status_enabled` (env `ORCH_STOP_STATUS_ENABLED`) по образцу + `serial_gate_enabled`; `False` → STOP-обработка и закрытие дыры релонча ведут себя нейтрально + (поведение строго как сейчас, нулевая регрессия). При необходимости — область репо + (`stop_status_repos`, CSV) с дефолтом «все репо» (отмена осмысленна и для enduro). +- **Аддитивность БД (NFR-2):** только идемпотентные миграции; enduro при выключенном/неприменимом + флаге не затрагивается. +- **Инварианты (не нарушать):** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_*`, exit-коды + deploy-хука, merge-gate (ORCH-043), merge-verify (ORCH-071/073), image-freshness (ORCH-058), + post-deploy контракт (ORCH-021), serial-gate (ORCH-088), auto-label (ORCH-089), семантика + Rejected/Approved/Confirm Deploy — **без изменений**. +- **Self-hosting safety (NFR-3):** STOP не рестартит/не роняет прод-контейнер; не push/force-push в + `main`; merge/deploy прерываются fail-safe (без half-merge). +- **never-raise (NFR-5):** обработчик STOP и закрытие релонча не валят вебхук-поток; ошибка на + единице работы изолирована. +- **Артефакты pipeline (создать/обновить в том же PR):** `docs/work-items/ORCH-090/06-adr/ADR-001-…` + (решение архитектора), `docs/architecture/README.md` (раздел «STOP / отмена задачи (ORCH-090)», + обновление описания `GET /queue`, раздела статусной модели и при новой таблице/колонке — раздела + «База данных»), `CLAUDE.md` (абзац о STOP в статусной модели), `CHANGELOG.md` (`feat:`); при новой + таблице/колонке — `docs/work-items/ORCH-090/08-data-requirements.md`; при админ-эндпоинте — таблица + API в README. + +--- + +## 8. Открытые вопросы для архитектора (не блокируют анализ) + +- OQ-1: Имя Plane-статуса — отдельный «STOP» (новый key) vs переиспользование существующего + «Cancelled» (key `cancelled` уже в `_PLANE_NAME_TO_KEY`). Влияет на маппинг и группу терминал-скипа. +- OQ-2: Статус отменённого job — новый `cancelled` vs `failed`+маркер. +- OQ-3: Состояние отменённой задачи — терминальная стадия `cancelled` vs аддитивная колонка/таблица. +- OQ-4: Сброс прогресса — удалить строку task (полный re-create через To Analyse) vs пометить + cancelled и при To Analyse создавать новую задачу. +- OQ-5: Удаление vs архив рабочей ветки (и Gitea-ветки) — что безопаснее для аудита. +- OQ-6: Точка безопасного прерывания merge/deploy (FR-7) и обработка удерживаемого merge-lease. +- OQ-7: Чем заменить легитимный «resume после Needs Input», который сейчас опирается на релонч в + `handle_status_start` (FR-6), чтобы не сломать намеренный сценарий возврата к работе. diff --git a/docs/work-items/ORCH-090/03-acceptance-criteria.md b/docs/work-items/ORCH-090/03-acceptance-criteria.md new file mode 100644 index 0000000..791a03b --- /dev/null +++ b/docs/work-items/ORCH-090/03-acceptance-criteria.md @@ -0,0 +1,146 @@ +--- +work_item: ORCH-090 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 03 — Критерии приёмки (Acceptance Criteria): ORCH-090 — Механизм отмены задачи: статус STOP в Plane + +Work Item: **ORCH-090** · Repo: **orchestrator** · Стадия: analysis + +Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** +(что считается провалом). Любой машинный/ручной reviewer проверяет их буквально по файлам +репозитория. + +--- + +## AC-1 — STOP останавливает активного агента + +**Условие:** задача с running-job'ом переведена в Plane-статус STOP. +- **PASS:** активному процессу агента послан SIGTERM через существующий каскад + (`launcher._watchdog`: SIGTERM → grace → SIGKILL); по grace процесс завершён; `agent_runs`/`jobs` + отражают завершение. Тест демонстрирует вызов остановки по `jobs.pid`. +- **FAIL:** процесс агента продолжает работать после STOP, либо kill реализован новым «грязным» + механизмом мимо graceful-каскада, либо STOP падает с исключением. + +--- + +## AC-2 — Все job'ы задачи отменены без авто-requeue + +**Условие:** у задачи есть job'ы в `queued` и/или `running`; пришёл STOP. +- **PASS:** все job'ы задачи переведены в терминальный отменённый исход; `claim_next_job` их не + выбирает; `_finalize_permanent`/`job_reaper` не возвращают их в `queued` (ретраи исчерпаны). + Тест: после STOP claim не возвращает job задачи, reaper не requeue'ит. +- **FAIL:** хотя бы один job задачи остаётся claimable/возвращается в `queued` после STOP, либо + происходит авто-requeue. + +--- + +## AC-3 — Таймеры/мониторы сняты, отменённая задача не реконсилируется + +**Условие:** задача отменена через STOP. +- **PASS:** связанные таймеры/мониторы (post-deploy monitor, brd-review clock, defer'ы) не активны + для задачи; `reconciler` (`_is_terminal_state`, терминал-скип `done`/`cancelled`) и `job_reaper` + не трогают/не «оживляют» отменённую задачу. Тест: reconciler F-1 пропускает отменённую задачу. +- **FAIL:** монитор/таймер срабатывает по отменённой задаче, либо reconciler/reaper её + релончит/реанимирует. + +--- + +## AC-4 — Полный сброс: ветка/worktree удалены/архивированы, прогресс сброшен, docs сохранены + +**Условие:** задача отменена через STOP. +- **PASS:** рабочий worktree удалён (`remove_worktree`, never-raise), рабочая ветка удалена/ + заархивирована; `main` не тронут (force-push в `main` отсутствует); прогресс задачи в БД приведён + к durable-состоянию «отменена» (повторный запуск возможен только с нуля); docs-артефакты + (`docs/work-items/ORCH-090/01..17`) **сохранены/забэкаплены**, не удалены. +- **FAIL:** worktree/ветка остаются как «живой» прогресс, либо тронут `main`, либо docs-артефакты + удалены, либо задача способна продолжиться «с середины». + +--- + +## AC-5 — Единственный вход к запуску — To Analyse; дыра релонча закрыта + +**Условие:** существующая задача (с веткой/прогрессом) вручную переведена в промежуточный рабочий +статус (Architecture/Development/Review/Testing/Deploying/Awaiting Deploy/Monitoring). +- **PASS:** агент соответствующей стадии **не** запускается (нет `enqueue_job` стадийного агента по + факту ручной смены рабочего статуса). Запуск пайплайна происходит ТОЛЬКО при статусе «To Analyse» + (`start_pipeline`). Тест: перевод в Development не порождает job; перевод в To Analyse порождает + старт с нуля. +- **FAIL:** ручной перевод в любой промежуточный рабочий статус релончит агента текущей стадии + (текущее дырявое поведение `handle_status_start`). + +--- + +## AC-6 — Идемпотентность STOP + +**Условие:** STOP приходит на задачу, которая уже отменена / `done` / не существует. +- **PASS:** обработчик — no-op: нет повторного kill, нет повторного удаления ветки, нет ошибок, нет + Telegram-спама дублями. Тест: повторный STOP не меняет состояние и не бросает. +- **FAIL:** повторный STOP бросает исключение, повторно убивает/чистит, либо генерирует + дубль-уведомления. + +--- + +## AC-7 — Безопасное прерывание merge/deploy (self-hosting safety) + +**Условие:** STOP приходит во время merge/deploy задачи. +- **PASS:** `main` не остаётся в half-merged состоянии; прод-контейнер не рестартится/не роняется + обработчиком STOP; force-push в `main` отсутствует. Если необратимый шаг уже запущен — он не + «разрывается» с порчей (исход зафиксирован честно, затем применена отмена). Тест/обоснование + демонстрирует fail-safe точку прерывания. +- **FAIL:** после STOP `main` в неконсистентном состоянии, прод перезапущен/упал по вине STOP, либо + выполнен force-push в `main`. + +--- + +## AC-8 — Kill-switch и нулевая регрессия + +**Условие:** флаг `stop_status_enabled=False`. +- **PASS:** STOP-обработка не активна, дыра релонча в поведении не меняется относительно текущего + кода; `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` не изменены; полный `pytest tests/` зелёный; + enduro-trails не затронут. При `True` — STOP работает по AC-1…AC-7. +- **FAIL:** при выключенном флаге поведение отличается от текущего; изменены exit-гейты/реестр QG; + регресс существующих тестов. + +--- + +## AC-9 — Аддитивность БД и restart-safe + +**Условие:** изменения схемы БД и поведение после рестарта. +- **PASS:** все миграции аддитивны и идемпотентны (`CREATE TABLE IF NOT EXISTS`/`_ensure_column`); + после рестарта контейнера отменённая задача остаётся отменённой и не релончится. Тест: повторная + инициализация БД не падает; отменённая задача durable. +- **FAIL:** деструктивная/неидемпотентная миграция, изменение существующих таблиц-контрактов, либо + «оживание» отменённой задачи после рестарта. + +--- + +## AC-10 — Наблюдаемость STOP + +**Условие:** STOP применён к задаче. +- **PASS:** факт отмены залогирован; отправлен Telegram-алерт с кликабельным номером задачи; + Plane-коммент (best-effort); live-карточка обновлена (never-raise); `GET /queue` несёт read-only + блок отмены. Тест: блок присутствует в ответе `GET /queue`. +- **FAIL:** STOP не оставляет следов в логе/уведомлениях, либо `GET /queue` падает/не отражает + отмену. + +--- + +## Сводная матрица AC ↔ FR/BR + +| AC | Покрывает | +|----|-----------| +| AC-1 | BR-1 / FR-2 | +| AC-2 | BR-1 / FR-3 | +| AC-3 | BR-1 / FR-4 / NFR-4 | +| AC-4 | BR-2 / FR-5 | +| AC-5 | BR-3, BR-4 / FR-6 | +| AC-6 | BR-5 / FR-1 | +| AC-7 | BR-6 / FR-7 / NFR-3 | +| AC-8 | NFR-1 / FR-6 | +| AC-9 | NFR-2, NFR-4 / FR-3, FR-5 | +| AC-10 | BR-8 / FR-8 | diff --git a/docs/work-items/ORCH-090/04-test-plan.yaml b/docs/work-items/ORCH-090/04-test-plan.yaml new file mode 100644 index 0000000..29a4021 --- /dev/null +++ b/docs/work-items/ORCH-090/04-test-plan.yaml @@ -0,0 +1,107 @@ +work_item: ORCH-090 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +title: "STOP-статус: отмена задачи (остановка + полный сброс) и закрытие дыры релонча" +framework: pytest +scope: > + Покрывается: распознавание/маршрутизация STOP, остановка агента, отмена job'ов без авто-requeue, + снятие мониторов/терминал-скип, полный сброс ветки/worktree/прогресса при сохранении docs, + закрытие дыры релонча (только To Analyse стартует пайплайн), идемпотентность, kill-switch, + аддитивность БД/restart-safe, наблюдаемость (GET /queue, уведомления). + Вне покрытия: реальный прод-деплой/рестарт контейнера (self-hosting safety проверяется на уровне + «не вызывается рестарт/force-push», а не живым деплоем); кросс-проектная пакетная отмена. +notes: > + Полный регресс `pytest tests/` должен оставаться зелёным (NFR-1). Регрессом считается: изменение + STAGE_TRANSITIONS/QG_CHECKS/check_*, релонч агента ручной сменой рабочего статуса, авто-requeue + отменённого job, «оживание» отменённой задачи reconciler/reaper, любой push/force-push в main, + рестарт прод-контейнера обработчиком STOP. Тесты должны проходить и при stop_status_enabled=False + (нейтральное поведение). Использовать существующие фикстуры из tests/test_plane_webhook.py / + test_launcher.py / test_queue.py / test_reconciler.py. + +tests: + - id: TC-01 + type: unit + description: "STOP-статус распознаётся и маршрутизируется в обработчик отмены (handle_issue_updated); неизвестная/прочая задача -> no-op, never-raise." + module: tests/test_stop_status.py + expected: PASS + + - id: TC-02 + type: unit + description: "Остановка активного агента: при STOP по running-job посылается SIGTERM по jobs.pid через каскад _watchdog (SIGTERM->grace->SIGKILL); нет активного процесса -> no-op." + module: tests/test_stop_status.py + expected: PASS + + - id: TC-03 + type: unit + description: "Отмена job'ов: queued+running job'ы задачи переведены в терминальный отменённый исход; claim_next_job их не выбирает (AC-2)." + module: tests/test_stop_status.py + expected: PASS + + - id: TC-04 + type: unit + description: "Запрет авто-requeue: _finalize_permanent/job_reaper не возвращают отменённый job в queued (ретраи исчерпаны)." + module: tests/test_stop_status.py + expected: PASS + + - id: TC-05 + type: unit + description: "Полный сброс: при STOP вызывается remove_worktree и удаление/архив рабочей ветки; main не трогается; force-push в main отсутствует (AC-4, AC-7)." + module: tests/test_stop_status.py + expected: PASS + + - id: TC-06 + type: unit + description: "Docs-артефакты задачи (01..17) сохраняются/бэкапятся при сбросе прогресса, не удаляются (AC-4)." + module: tests/test_stop_status.py + expected: PASS + + - id: TC-07 + type: unit + description: "Идемпотентность: повторный STOP на уже отменённой / done / несуществующей задаче -> no-op (нет повторного kill/cleanup, нет исключений, нет дубль-уведомлений) (AC-6)." + module: tests/test_stop_status.py + expected: PASS + + - id: TC-08 + type: unit + description: "Kill-switch: при stop_status_enabled=False STOP-обработка нейтральна, поведение как сейчас; при True -> отмена выполняется (AC-8)." + module: tests/test_stop_status.py + expected: PASS + + - id: TC-09 + type: unit + description: "Наблюдаемость: GET /queue несёт read-only блок отмены; never-raise при ошибке построения блока (AC-10)." + module: tests/test_stop_status.py + expected: PASS + + - id: TC-10 + type: integration + description: "Закрытие дыры релонча: ручной перевод существующей задачи в Development/Architecture/Review/Testing НЕ порождает job стадийного агента (handle_status_start не релончит) (AC-5)." + module: tests/test_stop_status.py + expected: PASS + + - id: TC-11 + type: integration + description: "Единственный вход: перевод в To Analyse запускает start_pipeline (новая ветка от свежего origin/main + analyst) — единственный путь старта пайплайна (AC-5)." + module: tests/test_stop_status.py + expected: PASS + + - id: TC-12 + type: integration + description: "Терминал-скип/restart-safe: отменённая задача durable; reconciler F-1 и job_reaper её не реконсилируют/не оживляют (терминал-скип done/cancelled, _is_terminal_state) (AC-3, AC-9)." + module: tests/test_stop_status.py + expected: PASS + + - id: TC-13 + type: integration + description: "End-to-end STOP: задача со срезанной веткой и активным job -> STOP -> агент остановлен, job'ы отменены, ветка/worktree убраны, статус задачи durable 'отменена', уведомления отправлены (AC-1..AC-4, AC-10)." + module: tests/test_stop_status.py + expected: PASS + + - id: TC-14 + type: unit + description: "Аддитивность БД: миграция нового терминального исхода job/состояния задачи идемпотентна (повторная init_db не падает); существующие таблицы-контракты не изменены (AC-9, NFR-2)." + module: tests/test_stop_status.py + expected: PASS -- 2.49.1 From ab083ba826ff1741ddc1deff987f2e2eca4767b3 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 20:37:21 +0300 Subject: [PATCH 3/9] architect(ET): auto-commit from architect run_id=497 --- docs/architecture/README.md | 39 +++ .../adr/adr-0026-stop-cancel-task.md | 106 +++++++ docs/architecture/internals.md | 8 +- .../06-adr/ADR-001-stop-cancel-task.md | 294 ++++++++++++++++++ .../ORCH-090/07-infra-requirements.md | 51 +++ .../ORCH-090/08-data-requirements.md | 70 +++++ docs/work-items/ORCH-090/10-tech-risks.md | 42 +++ 7 files changed, 609 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/adr/adr-0026-stop-cancel-task.md create mode 100644 docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md create mode 100644 docs/work-items/ORCH-090/07-infra-requirements.md create mode 100644 docs/work-items/ORCH-090/08-data-requirements.md create mode 100644 docs/work-items/ORCH-090/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 31ffe17..8d89675 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -278,6 +278,45 @@ Phase A ждёт ручного `Confirm Deploy`, ORCH-059). ORCH-089 снима `docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md`, `docs/work-items/ORCH-089/07-infra-requirements.md`. +### STOP / отмена задачи: терминал `cancelled` + закрытие дыры релонча (ORCH-090 — design) + +До ORCH-090 не было штатного способа отменить задачу (ручная хирургия по БД/процессам) и +существовала **дыра релонча**: `handle_status_start` при существующей задаче без активного job +безусловно релончил агента текущей стадии на той же ветке. ORCH-090 вводит Plane-статус **STOP** +как единый декларативный сигнал отмены: остановка агента + **полный сброс** прогресса. Аддитивно, +под kill-switch, never-raise, restart-safe; `STAGE_TRANSITIONS` (exit-гейты) / `QG_CHECKS` / +`check_*` — **без изменений**. +- **Новое системное терминальное состояние `cancelled`** (adr-0026) — `tasks.stage='cancelled'` + + `jobs.status='cancelled'`, равноправное `done`. Предикат «задача незавершена» расширяется + `stage != 'done'` → `stage NOT IN ('done','cancelled')` в `serial_gate` (ORCH-088) и `task_deps` + (ORCH-026), приводя их в соответствие с уже существующим терминал-скипом реконсилятора + (`stage in ("done","cancelled")`, ORCH-086 D2). Иначе отменённая задача заклинила бы очередь репо. +- **Распознавание (fail-closed):** новый ключ `stop` в `_PLANE_NAME_TO_KEY` (`"STOP" → "stop"`); + **не** в `_DEFAULT_STATES` (по образцу `confirm_deploy`/ORCH-059) → нет статуса = нет отмены, без + `KeyError`. `handle_issue_updated` маршрутизирует `stop` → новый `handle_stop` → + `stage_engine.cancel_task`. +- **Каскад отмены:** graceful SIGTERM активному агенту (переиспользование каскада + `launcher._watchdog` по `jobs.pid`); `cancel_jobs_for_task` (queued/running → `cancelled`, + не реквью'ятся); снятие таймеров/мониторов (brd-clock, post-deploy monitor, defer'ы); + `remove_worktree` + never-raise удаление **только feature-ветки** Gitea (`main` неприкосновенен, + без force-push); **тумбстон** `plane_id`/`work_item_id` (`#cancelled-`) → повторный + «To Analyse» создаёт задачу с нуля; docs-артефакты (`01..17`) сохраняются. +- **Безопасное прерывание merge/deploy:** STOP в критическом окне (self-deploy `INITIATED`-sentinel + ORCH-036, держание merge-lease ORCH-043/071) → **отложенная отмена** (durable + `cancel_requested_at`, отмена только `queued`-job'ов, алерт); необратимый шаг доводится до + честного исхода; `main`/прод-контейнер не трогаются (NFR-3). +- **Закрытие дыры релонча:** relaunch в `handle_status_start` ограничен стадией `analysis` + (единственный владелец Needs-Input, ORCH-066) — тихий релонч середины пайплайна на старой ветке + устранён; единственный вход к запуску — «To Analyse» (`start_pipeline`). +- **Флаги/наблюдаемость:** kill-switch `stop_status_enabled` + `stop_status_repos` (CSV, пусто → + все репо); leaf `src/cancel.py` (never-raise); read-only блок `stop` в `GET /queue`; лог + + Telegram (кликабельный номер) + Plane-коммент + live-карточка. При выключенном флаге — нулевая + регрессия (enduro не затронут). + +Подробнее: [adr-0026](adr/adr-0026-stop-cancel-task.md), детально — +`docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md`, +`docs/work-items/ORCH-090/08-data-requirements.md`. + ### Исполняемый самодеплой стадии `deploy` (ORCH-36) `deploy` перестаёт быть «бумажной»: для self-hosting (`is_self_hosting_repo`) стадия РЕАЛЬНО деплоит прод (8500) через хост-хук `scripts/orchestrator-deploy-hook.sh`, diff --git a/docs/architecture/adr/adr-0026-stop-cancel-task.md b/docs/architecture/adr/adr-0026-stop-cancel-task.md new file mode 100644 index 0000000..e3b8b80 --- /dev/null +++ b/docs/architecture/adr/adr-0026-stop-cancel-task.md @@ -0,0 +1,106 @@ +--- +work_item: ORCH-090 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# ADR-0026: Системное терминальное состояние `cancelled` — STOP-отмена задачи + +Сквозной (cross-cutting) ADR. Детальное решение задачи — +`docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md`. + +## Статус +Proposed + +## Контекст + +ORCH-090 вводит Plane-статус **STOP** — единый декларативный механизм отмены задачи (остановка +агента + полный сброс прогресса). Самое́ кросс-каттинговое следствие — появление **нового +системного терминального состояния `cancelled`** (стадия `tasks.stage='cancelled'` + терминальный +job-статус `jobs.status='cancelled'`). До ORCH-090 «терминальность задачи» в горячем планировщике +была захардкожена как **`stage == 'done'`** (единственный сток в `STAGE_TRANSITIONS`), и это +определение разъехалось между подсистемами: + +- `src/reconciler.py` **уже** трактует `stage in ("done","cancelled")` как терминал-скип + (ORCH-086 D2 предвосхитил `cancelled`; стр. 196) и `_is_terminal_state` по группе Plane + `{completed, cancelled}` (ORCH-068, стр. 398–415). +- `src/serial_gate.py` (ORCH-088) и `src/task_deps.py` (ORCH-026) считают задачу «незавершённой» + по `stage != 'done'` — **без** `cancelled`. Если ввести `cancelled`-стадию, не тронув их, + отменённая задача навсегда будет «активной»/«незавершённой зависимостью» и **заклинит очередь + репо**. + +Этот ADR фиксирует `cancelled` как первоклассное терминальное состояние, равноправное `done`, и +перечисляет ВСЕ точки, где системный предикат терминальности должен его признавать. + +## Решение + +### Инвариант +**«Задача терминальна» ⇔ `stage ∈ {done, cancelled}`.** Это единое определение для всех +подсистем планировщика/мониторинга. `cancelled` — терминальный **сток** (не новое ребро +конвейера): exit-гейты рёбер `STAGE_TRANSITIONS` и реестр `QG_CHECKS`/`check_*` **не меняются**. + +### Точки, признающие `cancelled` терминальным (исчерпывающе) +1. `src/stages.py::STAGE_TRANSITIONS` — добавить сток + `"cancelled": {"next": None, "agent": None, "qg": None}` (параллельно `done`). +2. `src/serial_gate.py` — `repo_has_other_unfinished` и claim-фрагмент `t2.stage != 'done'`, + snapshot: `stage != 'done'` → `stage NOT IN ('done','cancelled')`. **(маркер ORCH-088)** +3. `src/task_deps.py` — dep-gate и `is_task_ready`: `stage != 'done'` → + `stage NOT IN ('done','cancelled')`. **(маркер ORCH-026)** +4. `src/reconciler.py` — уже покрыто скипом `stage in ("done","cancelled")` (стр. 196); + `get_active_tasks_for_reconcile` опционально сузить до `NOT IN ('done','cancelled')`. +5. `src/job_reaper.py` / `src/queue_worker.py` — перед авто-requeue dead/running-job'а сверять + терминал задачи: `stage in ("done","cancelled")` → job помечается `cancelled`, не реквью'ится. +6. `src/post_deploy.py` / `stage_engine.run_post_deploy_monitor` — монитор не тикает по + отменённой задаче (терминал-проверка/маркер `done`). + +### Новые терминальные исходы +- **Job:** `jobs.status='cancelled'` — нигде не реквью'ится; `claim_next_job` выбирает только + `status='queued'` (изменений в claim нет). `mark_job` стампит `finished_at` для `cancelled`. +- **Задача:** `tasks.stage='cancelled'` + аддитивные колонки `cancelled_at`, + `cancel_requested_at` (отложенная отмена в критическом окне merge/deploy). Натуральные ключи + `plane_id`/`work_item_id` тумбстонятся (`#cancelled-`) для переиспользования «To Analyse» + с нуля; `plane_issue_id` сохраняется (аудит). Детали — 08-data-requirements.md. + +### Точки врезки STOP (компоненты) +- `plane.py` — маршрут `stop` (fail-closed, не в `_DEFAULT_STATES`) → `handle_stop`; гейт релонча + ограничен стадией `analysis`. +- `stage_engine.cancel_task` — оркестрация отмены (graceful SIGTERM, cancel-jobs, worktree+branch, + tombstone, notify); безопасное прерывание merge/deploy (D7 локального ADR). +- leaf `src/cancel.py` — чистая логика (`applies`/`in_critical_window`/`snapshot`), never-raise. +- `src/gitea.py` — `delete_remote_branch` (never-raise; только feature-ветка, `main` неприкосновенен). +- `GET /queue` — read-only блок `stop`. + +### Флаги / совместимость +- Kill-switch `stop_status_enabled` + scope `stop_status_repos` (CSV, пусто → все репо). +- При `stop_status_enabled=False`: STOP-обработка и гейт релонча инертны; расширение + терминал-набора `cancelled` безвредно при отсутствии отменённых задач → **нулевая регрессия**. +- `STAGE_TRANSITIONS` (exit-гейты) / `QG_CHECKS` / `check_*` / семантика + Approved/Rejected/Confirm Deploy / merge-gate (ORCH-043) / merge-verify (ORCH-071/073) / + image-freshness (ORCH-058) / post-deploy (ORCH-021) / serial-gate FIFO (ORCH-088) / auto-label + (ORCH-089) — **без изменений**. +- Миграции БД — только аддитивные/идемпотентные (`_ensure_column`); enduro не затронут (NFR-2). + +## Последствия +- **+** Единое, консистентное определение терминальности — устранён латентный рассинхрон + `done`-only между планировщиком и реконсилятором. +- **+** STOP безопасен для self-hosting: не трогает `main`/прод, отложенная отмена в критическом + окне. +- **−** Терминальность теперь читается из набора `{done, cancelled}`, а не из скаляра `'done'` — + будущие подсистемы обязаны использовать набор. Митигейшн: этот ADR + маркер `ORCH-090` в + изменённых местах + тесты. +- **Откат:** `stop_status_enabled=False`; полный revert — снять врезки и вернуть предикаты к + `stage != 'done'`. + +## Эволюция маркеров `cancelled`-терминала +Места, признающие `cancelled` терминальным (см. список выше), несут маркер `ORCH-090`. Правка +любого из них — сверяться с этим ADR (анти-археология: 3+ маркеров → одна ссылка сюда, +TRACEABILITY.md). + +## Ссылки +- Детальный ADR: `docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md` +- Data: `docs/work-items/ORCH-090/08-data-requirements.md` +- Связанные: adr-0017 (serial-gate), adr-0015 (task-deps), adr-0007 (self-deploy), + adr-0006 (merge-gate), adr-0018 (auto-label) diff --git a/docs/architecture/internals.md b/docs/architecture/internals.md index 270a254..82980d7 100644 --- a/docs/architecture/internals.md +++ b/docs/architecture/internals.md @@ -61,9 +61,15 @@ STAGE_TRANSITIONS = { testing: → deploy-staging (agent: deployer, QG: check_tests_passed) deploy-staging: → deploy (agent: deployer, QG: check_staging_status) deploy: → done (agent: None, QG: None) + cancelled: → None (agent: None, QG: None) # ORCH-090: терминал-сток отмены } ``` +**Терминальные стоки (ORCH-090):** `done` и `cancelled` — равноправные терминальные состояния +(`{"next": None, "agent": None, "qg": None}`). `cancelled` — это **не новое ребро** (exit-гейты +рёбер не меняются), а терминал STOP-отмены. Системный предикат «задача завершена» — +`stage ∈ {done, cancelled}` (синхронно в `reconciler`/`serial_gate`/`task_deps`; adr-0026). + ### 3. Quality Gates (`src/qg/checks.py`) | Check | Метод проверки | @@ -329,7 +335,7 @@ webhook (plane/gitea) background thread (queue_worker) | Колонка | Назначение | |--------|------------| -| `status` | `queued` → `running` → `done` \| `failed` | +| `status` | `queued` → `running` → `done` \| `failed` \| `cancelled` (ORCH-090: терминальный исход STOP-отмены, не реквью'ится) | | `attempts` / `max_attempts` | счётчик попыток (инкремент при claim) / лимит ретраев (default 2) | | `run_id` | FK на `agent_runs.id` после старта | | `pid` | (ORCH-065) pid агентского процесса (`proc.pid` из `_spawn`); liveness-сигнал для job-reaper. Добавляется `_ensure_column` (idempotent) | diff --git a/docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md b/docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md new file mode 100644 index 0000000..071ffc0 --- /dev/null +++ b/docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md @@ -0,0 +1,294 @@ +--- +work_item: ORCH-090 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# ADR-001: Механизм отмены задачи — Plane-статус STOP (остановка + полный сброс) + +Work Item: **ORCH-090** — единый декларативный механизм отмены/сброса задачи через +Plane-статус STOP. +Стадия: **architecture** +Сквозная регистрация: **`docs/architecture/adr/adr-0026-stop-cancel-task.md`** (решение +кросс-каттинговое — вводит системное терминальное состояние `cancelled`, затрагивающее +планировщик, реконсилятор, serial-gate, task-deps, мониторы). + +## Статус +Proposed + +--- + +## Контекст + +Сегодня в оркестраторе **нет штатного способа отменить/остановить задачу** (BRD §1). Оператор +делает ручную хирургию: убивает процесс агента, ждёт исчерпания ретраев job, чистит +ветку/worktree/строку `tasks` и сбрасывает статус Plane. Медленно, ошибкоопасно, +невоспроизводимо (инцидент 09.06 с ORCH-087). + +Вторая, связанная проблема — **дыра релонча**. Сверено по коду +`src/webhooks/plane.py::handle_status_start` (строки 215–306): при существующей задаче без +активного job функция **безусловно релончит агента текущей стадии** на той же ветке +(`has_active_job_for_task(task_id)` → иначе `enqueue_job(stage_agent, …)`, где +`stage_agent = STAGE_AUTHORS.get(current_stage)`). Этот путь задуман для «аналитик ответил на +Needs Input», но релончит агента **любой** стадии — именно он усугубил инцидент. + +**Факты, сверенные по коду (не изобретать):** +- Машина стадий — `src/stages.py::STAGE_TRANSITIONS`; `done` — терминальный сток + (`{"next": None, "agent": None, "qg": None}`, строка 21). `cancelled`-стадии нет. +- Plane-маппинг — `src/plane_sync.py`: `_PLANE_NAME_TO_KEY` уже содержит + `"Cancelled" → "cancelled"` (стр. 141); `_DEFAULT_STATES` содержит UUID `cancelled` (стр. 102); + имени «STOP» в маппинге нет. Маршрутизация статуса — `handle_issue_updated` (стр. 129–173), + сравнивает `new_state` с per-project UUID из `get_project_states(project_id)`; `to_analyse → + handle_status_start`, `confirm_deploy → handle_confirm_deploy`, `approved/rejected → + handle_verdict`; всё прочее → `else` (no-op). +- Остановка процесса агента уже есть — graceful-каскад `launcher._watchdog` + (SIGTERM → grace `agent_kill_grace_seconds` → SIGKILL, стр. 661–718); PID задачи стампится в + `jobs.pid` (`_spawn`, стр. 607–614). +- Статусы job в `jobs` — `queued | running | done | failed` (`src/db.py`, стр. 56–72); claim + выбирает только `status='queued'` (`claim_next_job`, стр. 586–651). Реквью на dead-running — + `job_reaper._reap_unknown_outcome` (`attempts/.deploy-state-//` + (ORCH-036); +- задача держит merge-lease `/.merge-lease-.json` / merge в процессе (ORCH-043/071). + +**Вне критического окна** — полный сброс немедленно (D2–D4, D8). +**Внутри критического окна** — отложенная отмена: ставится durable-метка +`tasks.cancel_requested_at` (аддитивная колонка), отменяются **только `queued`** job'ы (не +running-актор деплоя/мержа), шлётся алерт «STOP отложен до завершения критичного шага». +Детерминированный finalizer (`run_deploy_finalizer` Phase C / `_handle_merge_verify`) **доводит +необратимый шаг до честного исхода** и на терминальном `advance_stage` сверяется с +`cancel_requested_at`: задача переводится в `cancelled` с очисткой (worktree/ветка; код, уже +влитый в `main`, **не откатывается** — rollback вне объёма, BRD §2). Если шаг достиг `done` — +STOP фиксируется как «no-op после завершения» (честно: код уже в проде). Так AC-7 выполняется без +порчи `main`/прода. + +### D8 — Полный сброс ветки/worktree, сохранение docs (FR-5, BR-2, AC-4) + +- `git_worktree.remove_worktree(repo, branch)` — снять worktree (never-raise, уже есть). +- **Удалить удалённую feature-ветку** через новый never-raise хелпер + `gitea.delete_remote_branch(repo, branch)` (Gitea `DELETE /repos/{owner}/{repo}/branches/{branch}`). + Удаляется **только** ветка задачи; `main` — никогда; force-push отсутствует. Выбор «удалить» (не + архив): ветку легко восстановить из Gitea, а аналитику хранят docs — минимум новой Gitea-логики + (OQ-5). +- **Docs-артефакты (`01..17`) сохраняются** — не удаляются. На диске они в `docs/work-items/ORCH-090/` + (merge'ятся отдельным PR); cancel их не трогает. (Бэкап = они уже в `origin/main`/ветке docs.) + +### D9 — Флаги, leaf-модуль, наблюдаемость (FR-8, BR-8, NFR-1, AC-10) + +- `src/config.py`: `stop_status_enabled: bool = True` (env `ORCH_STOP_STATUS_ENABLED`, + kill-switch) + `stop_status_repos: str = ""` (CSV; **пусто → все репо**, отмена осмысленна и для + enduro; токены санитайзятся `^[A-Za-z0-9._-]+$`) — по образцу `serial_gate_*`. +- Leaf `src/cancel.py` (never-raise, импортирует только `config`/`db`, лениво `plane_sync`): чистая + логика — `applies(repo)`, `in_critical_window(task)`, `snapshot()`. Оркестрация + (SIGTERM/cancel-jobs/worktree/branch/tombstone/notify) — `stage_engine.cancel_task` (там уже есть + доступ к launcher/db/notifications/plane_sync). +- Наблюдаемость: `logger.info/warning`, Telegram-алерт (`send_telegram`, кликабельный + `plane_issue_link`), Plane-коммент (best-effort), `update_task_tracker` (never-raise), + read-only блок `stop` в `GET /queue` (`cancel.snapshot()`: `enabled`/`repos`/счётчик + `stage='cancelled'`/последние отмены). Существующие ключи `/queue` не меняются. + +--- + +## Альтернативы + +- **Переиспользовать существующий статус «Cancelled» (key `cancelled`) вместо нового «STOP»** — + отвергнуто: владелец продукта явно хочет операторскую кнопку «STOP», отличную от встроенного + Plane-«Cancelled» (которым наблюдатели могут пользоваться иначе). Терминал-семантику группы + `cancelled` мы при этом переиспользуем (D1, D5). +- **Job-статус `failed`+маркер вместо нового `cancelled`** (OQ-2) — отвергнуто: `failed` + семантически реквью-абелен (reaper/worker путь `attempts` детерминирован и парсится для аудита; `plane_issue_id` нетронут. +- **Откат:** `stop_status_enabled=False` отключает обработку STOP, гейт релонча и + freeze-неотносимые ветки; аддитивные колонки (`cancelled_at`/`cancel_requested_at`) и расширение + терминал-набора инертны при отсутствии отменённых задач. Полный revert — снять врезки в + `plane.py`/`stage_engine.py`/`serial_gate.py`/`task_deps.py`/`stages.py`, leaf `cancel.py`, флаги. + +--- + +## Ссылки +- BRD: `docs/work-items/ORCH-090/01-brd.md` +- TRZ: `docs/work-items/ORCH-090/02-trz.md` +- Acceptance: `docs/work-items/ORCH-090/03-acceptance-criteria.md` +- Data: `docs/work-items/ORCH-090/08-data-requirements.md` +- Infra: `docs/work-items/ORCH-090/07-infra-requirements.md` +- Риски: `docs/work-items/ORCH-090/10-tech-risks.md` +- Сквозной ADR: `docs/architecture/adr/adr-0026-stop-cancel-task.md` +- Сверено по коду: `src/webhooks/plane.py`, `src/plane_sync.py`, `src/db.py`, + `src/queue_worker.py`, `src/agents/launcher.py`, `src/reconciler.py`, `src/job_reaper.py`, + `src/serial_gate.py`, `src/task_deps.py`, `src/stages.py`, `src/git_worktree.py`, + `src/post_deploy.py`, `src/main.py` +- Маркеры (сверено перед изменением, TRACEABILITY.md): ORCH-088 (`serial_gate`), ORCH-026 + (`task_deps`), ORCH-086/068 (терминал-скип reconciler), ORCH-036/059 (self-deploy phases), + ORCH-043/071 (merge-gate/merge-verify), ORCH-021 (post-deploy), ORCH-087 (brd-clock) diff --git a/docs/work-items/ORCH-090/07-infra-requirements.md b/docs/work-items/ORCH-090/07-infra-requirements.md new file mode 100644 index 0000000..6a2d686 --- /dev/null +++ b/docs/work-items/ORCH-090/07-infra-requirements.md @@ -0,0 +1,51 @@ +--- +work_item: ORCH-090 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 07 — Инфра-требования: ORCH-090 — Механизм отмены задачи (STOP) + +Work Item: **ORCH-090** · Repo: **orchestrator** · Стадия: architecture + +## I-1. Топология / окружения + +Без изменения топологии. Тот же прод-контейнер `orchestrator` (8500) и staging (8501), та же +общая SQLite-БД и очередь. STOP — обработка вебхука внутри существующего сервиса; новых +контейнеров/портов/томов/сетей нет. + +**Инфра-предусловие (обязательно):** на доске Plane проекта ORCH создать статус **«STOP»** с +**группой `cancelled`** (а не `started`/`unstarted`). Группа `cancelled` обеспечивает нативный +терминал-скип реконсилятора (`_is_terminal_state`, ORCH-068/086) без доп-кода. До создания +статуса фича в fail-safe: `get_project_states(...).get("stop")` → `None` → ветка STOP не +активируется (нет `KeyError`, ничего не ломается). После создания — сбросить кэш состояний +(`reload_project_states`) или дождаться TTL `ORCH_PLANE_STATES_TTL_S` (дефолт 300с). + +> Для enduro-trails статус STOP **не** обязателен: `stop` отсутствует в `_DEFAULT_STATES` +> (fail-closed), отмена для enduro станет доступна только при создании статуса на их доске. + +## I-2. Переменные окружения / секреты + +Новые env (в `.env.example`, аддитивно; секретов нет): +- `ORCH_STOP_STATUS_ENABLED` — kill-switch фичи (дефолт `true`). +- `ORCH_STOP_STATUS_REPOS` — CSV области репо (дефолт пусто → все репо). + +Существующие переиспользуются: `ORCH_AGENT_KILL_GRACE_SECONDS` (graceful kill), Gitea-токен +(`delete_remote_branch`), Telegram-токен (алерт). Новых секретов нет. + +## I-3. Деплой / рестарт + +Прод-деплой орка — обязательно через staging-гейт (8501) перед `deploy` (self-hosting инвариант, +INFRA.md). STOP-обработчик сам **никогда** не рестартит/не роняет прод-контейнер и не трогает +`main` (NFR-3): при STOP во время self-deploy критичный detached-шаг не прерывается — отмена +откладывается до его честного завершения (ADR-001 D7). Раскат — поэтапно через `stop_status_repos` +при необходимости; дефолт «все репо». + +## I-4. CI/CD + +Без изменений `.gitea/workflows/`. Добавляются только pytest-тесты (`tests/`, см. +`04-test-plan.yaml`): STOP-каскад, запрет авто-requeue, терминал-скип, закрытие дыры релонча, +kill-switch, аддитивность миграций. diff --git a/docs/work-items/ORCH-090/08-data-requirements.md b/docs/work-items/ORCH-090/08-data-requirements.md new file mode 100644 index 0000000..e765708 --- /dev/null +++ b/docs/work-items/ORCH-090/08-data-requirements.md @@ -0,0 +1,70 @@ +--- +work_item: ORCH-090 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 08 — Требования к данным: ORCH-090 — Механизм отмены задачи (STOP) + +Work Item: **ORCH-090** · Repo: **orchestrator** · Стадия: architecture + +> Общая прод-БД (orchestrator + enduro). Все изменения — **только аддитивные и идемпотентные** +> (`_ensure_column`); существующие таблицы-контракты не переопределяются (NFR-2, AC-9). + +## Изменения схемы БД + +### Таблица `tasks` — аддитивные колонки (через `_ensure_column`) +| Колонка | Тип | Назначение | +|---------|-----|------------| +| `cancelled_at` | `TEXT` | durable-метка времени отмены (аудит/наблюдаемость). NULL для неотменённых. | +| `cancel_requested_at` | `TEXT` | durable-метка «отмена запрошена, но отложена» (STOP в критическом окне merge/deploy, ADR-001 D7). Снимается при доведении отмены до конца. | + +Никаких `ALTER` существующих колонок. `init_db` идемпотентен (повторный вызов — no-op). + +### Без DDL-изменений (расширение допустимых значений TEXT) +- **`jobs.status`** — добавляется значение `cancelled` к набору `queued|running|done|failed`. + Колонка уже `TEXT`; DDL не меняется. `claim_next_job` выбирает только `status='queued'` → + `cancelled` исключён нативно. +- **`tasks.stage`** — добавляется терминальное значение `cancelled` (сток, параллельно `done`). + Колонка уже `TEXT DEFAULT 'created'`; DDL не меняется. `STAGE_TRANSITIONS` exit-гейты рёбер + **не меняются** — `cancelled` это терминальное состояние, не новое ребро. + +### Без изменений +`job_deps`, `agent_runs`, `repo_freeze`, `tracker_messages`, индексы — контракты нетронуты. +`QG_CHECKS` / `check_*` — без изменений. + +## Новые/изменённые сущности + +### Тумбстон натуральных ключей отменённой задачи (ADR-001 D4) +На cancel выполняется UPDATE отменённой строки `tasks`: +- `plane_id := plane_id || '#cancelled-' || id` +- `work_item_id := work_item_id || '#cancelled-' || id` +- `stage := 'cancelled'`, `cancelled_at := datetime('now')` +- `plane_issue_id` — **сохраняется нетронутым** (аудит-связь с issue Plane). + +Цель: освободить натуральные ключи, чтобы повторный «To Analyse» создал свежую задачу +(`get_task_by_plane_id(plane_id)` → `None`; anti-dup `create_task_atomic` / +`ensure_unique_work_item_id` не коллизируют), сохранив строку для аудита. Формат суффикса +`#cancelled-` детерминирован и парсится. + +### Отмена job'ов (ADR-001 D3) +`cancel_jobs_for_task(task_id)` — guarded UPDATE +`SET status='cancelled', finished_at=datetime('now') WHERE task_id=? AND status IN ('queued','running')`. +Терминальный исход, нигде не реквью'ящийся. + +## Совместимость данных / миграции + +- **Аддитивность/идемпотентность:** только `_ensure_column` (no-op если колонка есть) и + расширение наборов TEXT-значений; деструктивных/несовместимых миграций нет (AC-9). Повторная + `init_db` после рестарта не падает. +- **Restart-safe (NFR-4):** durable терминал = `tasks.stage='cancelled'` (уже понимается + терминал-скипом реконсилятора, стр. 196). После рестарта `requeue_running_jobs` флипает только + `running` → отменённые job'ы (`cancelled`) не оживают; отменённая задача не реконсилируется. +- **Влияние на общую прод-БД:** изменения строго per-task; enduro не затрагивается, при + `stop_status_enabled=False` или отсутствии отменённых задач — поведение БД 1:1 как сейчас. +- **Кросс-каттинг (adr-0026):** предикат «задача незавершена» в `serial_gate`/`task_deps` + расширяется `stage != 'done'` → `stage NOT IN ('done','cancelled')`, иначе отменённая задача + заклинит очередь репо. Чтение БД (offline hot-path) не приобретает новых сетевых вызовов (NFR-6). diff --git a/docs/work-items/ORCH-090/10-tech-risks.md b/docs/work-items/ORCH-090/10-tech-risks.md new file mode 100644 index 0000000..b2bd8cb --- /dev/null +++ b/docs/work-items/ORCH-090/10-tech-risks.md @@ -0,0 +1,42 @@ +--- +work_item: ORCH-090 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-090 — Механизм отмены задачи (STOP) + +Work Item: **ORCH-090** · Repo: **orchestrator** · Стадия: architecture + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 | **Отменённая задача клинит очередь репо** — `serial_gate`/`task_deps` считают `cancelled` «незавершённой» (`stage != 'done'`) → serial-gate блокирует репо, dep-gate вечно держит зависимые. | Выс. | Выс. | Расширить предикат до `stage NOT IN ('done','cancelled')` во ВСЕХ точках (adr-0026, исчерпывающий список). Тест: после STOP другая задача репо стартует; зависимая разблокируется. | +| TR-2 | **Гонка reaper/worker реквью** — SIGTERM послан, job ещё `running`, reaper видит dead-pid → `attempts` на cancel (D4); `plane_issue_id` сохранён. Тест: после STOP «To Analyse» создаёт свежую задачу без коллизии. | +| TR-5 | **Очистка прогресса в общей прод-БД задевает enduro/другие задачи.** | Низ. | Выс. | Все операции строго per-`task_id`; тумбстон/cancel-jobs гардятся `WHERE task_id=?`; аддитивные миграции; при `stop_status_enabled=False` — инертно. Тест: enduro-строки не тронуты. | +| TR-6 | **Закрытие дыры релонча ломает легитимный resume аналитика после Needs Input.** | Сред. | Сред. | Relaunch ограничивается стадией `analysis` (единственный владелец Needs-Input, ORCH-066), а не блокируется целиком (D6). Тест: To Analyse на `analysis` релончит аналитика; на середине пайплайна — no-op. | +| TR-7 | **STOP на «Cancelled»-группе без явного статуса STOP** — fail-closed `stop` не в `_DEFAULT_STATES` может удивить (на доске нет статуса → отмены нет). | Низ. | Низ. | Документировано как fail-safe (07-infra); инфра-предусловие — создать статус STOP (группа `cancelled`). Наблюдаемость: блок `stop` в `/queue` показывает `enabled`/`repos`. | +| TR-8 | **Дубль-уведомления / повторный kill при повторном STOP.** | Низ. | Низ. | Идемпотентность (BR-5/D1): `stage in ("done","cancelled")` → no-op до любых действий. Тест: повторный STOP не меняет состояние и не шлёт дубль. | +| TR-9 | **`delete_remote_branch` падает / ветка уже удалена / Gitea недоступна.** | Низ. | Низ. | never-raise хелпер: ошибка/404 логируется, отмена продолжается; worktree снимается локально независимо; `main` не трогается. | +| TR-10 | **Удаление feature-ветки теряет код, не влитый в `main`.** | Низ. | Сред. | По замыслу: STOP = сброс незавершённого прогресса (BRD §2). docs-артефакты (`01..17`) сохраняются; ветку можно восстановить в Gitea. Влитый в `main` код не откатывается (rollback вне объёма). | + +## Сводный вывод + +Доминирующий класс — **консистентность системного терминал-набора** (TR-1, TR-2): введение +`cancelled` как первоклассного терминала обязывает синхронно обновить ВСЕ предикаты «задача +завершена», иначе латентный клин очереди. Это покрыто исчерпывающим списком в adr-0026 и +маркером `ORCH-090`. Второй класс — **self-hosting safety при STOP во время merge/deploy** (TR-3), +покрыт отложенной отменой (D7) с жёсткими запретами (`main`/прод/force-push/kill detached). + +**Эскалация:** решение вводит **новое системное терминальное состояние `cancelled`** (новая +стадия-сток + новый job-статус + сквозное изменение предиката терминальности) → классифицируется +как `arch:major-change`. Возврат в анализ **не требуется** — ТЗ полно, OQ-1…OQ-7 разрешены в +ADR-001; реализация аддитивна, под kill-switch, с нулевой регрессией при выключенном флаге. +Остаточный риск для прод-конвейера (self-hosting) — **низкий** при условии полного покрытия +тестами TR-1/TR-2/TR-3 и обязательного staging-гейта перед прод-деплоем. -- 2.49.1 From ebbf2e7a2db415468bfcc1eba133fc5471e53506 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 21:01:57 +0300 Subject: [PATCH 4/9] feat(cancel): STOP-status task cancellation + relaunch-hole close (ORCH-090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the dedicated Plane STOP status as a single declarative task-cancel mechanism: stop the active agent (graceful SIGTERM cascade), cancel all jobs (terminal `cancelled`, never requeued), remove the worktree + delete the remote feature branch (never main, never force-push), drive the task to the new system-terminal state `cancelled` and tombstone the natural keys so a later "To Analyse" re-creates it from scratch (docs artefacts preserved). STOP during a critical merge/deploy window is deferred until the irreversible step finishes honestly. Also closes the relaunch hole: handle_status_start relaunch is gated to the `analysis` stage; the only pipeline-start entry point remains "To Analyse". Cross-cutting (adr-0026): the "task terminal" predicate is widened {done} -> {done, cancelled} in serial_gate / task_deps / stages sink + reaper/worker requeue guards. STAGE_TRANSITIONS exit-gates / QG_CHECKS / check_* are unchanged (`cancelled` is a sink, not a new edge). Additive, never-raise, restart-safe, under kill-switch ORCH_STOP_STATUS_ENABLED (off -> zero regression). New: src/cancel.py (leaf), src/gitea.py (delete_remote_branch), tasks columns cancelled_at/cancel_requested_at, jobs status `cancelled`, GET /queue `stop` block. Tests: tests/test_stop_status.py (TC-01..TC-14 + D7); full suite green (1345). Docs updated in-PR (architecture README, CLAUDE.md, README.md, .env.example, CHANGELOG). ADR-001 D4 refinement: plane_issue_id is tombstoned too (the lookup ORs on it) — original UUID recoverable from the parseable suffix. Refs: ORCH-090 Co-Authored-By: Claude Opus 4.8 --- .env.example | 18 ++ CHANGELOG.md | 9 + CLAUDE.md | 33 ++ README.md | 27 +- docs/architecture/README.md | 21 +- src/agents/launcher.py | 54 +++- src/cancel.py | 144 +++++++++ src/config.py | 19 ++ src/db.py | 208 +++++++++++- src/gitea.py | 65 ++++ src/job_reaper.py | 10 + src/main.py | 5 + src/merge_gate.py | 15 + src/plane_sync.py | 7 + src/queue_worker.py | 10 +- src/serial_gate.py | 16 +- src/stage_engine.py | 201 ++++++++++++ src/stages.py | 7 + src/task_deps.py | 9 +- src/webhooks/plane.py | 77 ++++- tests/test_auto_labels_invariants.py | 3 +- tests/test_config.py | 8 +- tests/test_plane_status_model.py | 2 + tests/test_qg_registry_snapshot.py | 3 + tests/test_serial_gate.py | 4 +- tests/test_stages_invariants.py | 3 + tests/test_stop_status.py | 454 +++++++++++++++++++++++++++ 27 files changed, 1394 insertions(+), 38 deletions(-) create mode 100644 src/cancel.py create mode 100644 src/gitea.py create mode 100644 tests/test_stop_status.py diff --git a/.env.example b/.env.example index 9e4bf8d..9119703 100644 --- a/.env.example +++ b/.env.example @@ -121,6 +121,24 @@ ORCH_TASK_DEPS_SOURCE=db ORCH_SERIAL_GATE_ENABLED=true ORCH_SERIAL_GATE_REPOS= ORCH_SERIAL_GATE_FREEZE_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) +# routes to a cancel handler that drives the task to the system-terminal state +# `cancelled` (stop agent via the graceful SIGTERM cascade, cancel all jobs, remove +# worktree + delete the remote feature branch [never main / never force-push], +# tombstone the natural keys for a clean re-create via "To Analyse"; docs preserved). +# STOP during a critical merge/deploy window is DEFERRED until the irreversible step +# finishes honestly. The relaunch-hole gate restricts the "To Analyse" agent relaunch +# to the `analysis` stage (the sole Needs-Input owner). Additive, never-raise. +# Infra precondition: create a "STOP" status with the `cancelled` group on the ORCH +# board (07-infra-requirements.md). Leaf src/cancel.py. +# STOP_STATUS_ENABLED=false -> STOP handling AND the relaunch-hole gate are inert +# (behaviour strictly as before ORCH-090). +# STOP_STATUS_REPOS (CSV) -> scope; EMPTY = ALL repos (cancellation is meaningful +# for enduro too). +ORCH_STOP_STATUS_ENABLED=true +ORCH_STOP_STATUS_REPOS= # ORCH-071/073: merge-verify under-gate on the `deploy -> done` edge (врезка in # advance_stage, NOT a new STAGE_TRANSITIONS edge / registered QG). A deterministic # merge-actor merges the feature code-PR via the Gitea PR-merge API (never push/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a0c77e1..3c4cad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Отмена задачи: Plane-статус STOP (остановка агента + полный сброс) + закрытие дыры релонча** (ORCH-090, `feat`): выделенный Plane-статус **STOP** — единый декларативный механизм отмены задачи вместо ручной хирургии по БД/процессам. Вводит **новое системное терминальное состояние `cancelled`** (стадия `tasks.stage='cancelled'` + job-исход `jobs.status='cancelled'`), равноправное `done`. **Аддитивно, под kill-switch, never-raise, restart-safe:** `STAGE_TRANSITIONS` (exit-гейты рёбер) / `QG_CHECKS` / `check_*` / семантика существующих статусов — **не тронуты** (`cancelled` — терминальный сток, не новое ребро); enduro не затронут; при `stop_status_enabled=false` — нулевая регрессия. + - **Распознавание (fail-closed):** новый логический ключ `stop` в `_PLANE_NAME_TO_KEY` (`"STOP" → "stop"`), **намеренно отсутствует** в `_DEFAULT_STATES` (по образцу `confirm_deploy`/ORCH-059) → доска без статуса STOP резолвит `None` → ветка не активируется (нет `KeyError`, нет слепой отмены). `handle_issue_updated` маршрутизирует `stop` → `handle_stop` → `stage_engine.cancel_task` (проверяется ПЕРВЫМ, до to_analyse/approved/rejected). + - **Полный сброс (вне критичного окна, AC-1..AC-4):** graceful SIGTERM активного агента через переиспользуемый каскад `launcher.stop_process` (вынесен из `_watchdog`: SIGTERM → grace → SIGKILL) по `jobs.pid`; `db.cancel_jobs_for_task` (queued/running → терминальный `cancelled`, нигде не реквью'ится — `claim_next_job` берёт только `queued`); `git_worktree.remove_worktree` + новый never-raise `src/gitea.py::delete_remote_branch` (удаляет **только** feature-ветку; `main`/`master` — явный гард-отказ; без force-push); durable `stage='cancelled'` + `cancelled_at`; **тумбстон** натуральных ключей суффиксом `#cancelled-`. Docs-артефакты (`01..17`) сохраняются. + - **Уточнение ADR-001 D4 (при реализации):** ADR предлагал сохранить `plane_issue_id` нетронутым, но `get_task_by_plane_id`/`create_task_atomic` матчат по `plane_id OR plane_issue_id` — нетумбстоненный `plane_issue_id` заблокировал бы clean-slate re-create (BR-3/TR-4). Поэтому `plane_issue_id` тоже тумбстонится; исходный UUID (== исходный `plane_id` во всех путях создания) парсится из детерминированного суффикса для аудита. Зафиксировано в коде/`docs/architecture/README.md`/CLAUDE.md. + - **Безопасное прерывание merge/deploy (AC-7, NFR-3):** STOP в критическом окне (self-deploy `INITIATED`-sentinel ORCH-036 / держание merge-lease ORCH-043) → **отложенная отмена** (`cancel.in_critical_window` fail-CLOSED): durable `tasks.cancel_requested_at`, снимаются только `queued`-job'ы (running-актор деплоя/мержа не трогается), алерт; детерминированный `run_deploy_finalizer` доводит необратимый шаг до честного исхода и применяет отмену (`cancel_task(force=True)`; задача, дошедшая до `done`, — честный no-op, код уже в проде). STOP **никогда** не трогает `main`/force-push/прод-контейнер/detached-процесс. + - **Кросс-каттинг (adr-0026):** предикат «задача терминальна» расширен `{done}` → `{done, cancelled}` в `serial_gate.py` (ORCH-088: `repo_has_active_task`, claim-фрагмент, snapshot), `db.claim_next_job`/`get_unfinished_dependencies` (task_deps ORCH-026) и `stages.py`-сток — иначе отменённая задача заклинила бы очередь репо (TR-1); reconciler-терминал-скип уже знал `cancelled` (ORCH-086 D2). `job_reaper`/`queue_worker` ПЕРЕД авто-requeue сверяют терминал задачи → помечают job `cancelled`, не реквью'ят (закрыта гонка SIGTERM/reaper, TR-2). + - **Закрытие дыры релонча (AC-5, D6):** `handle_status_start` больше не релончит агента середины пайплайна при ручном переводе в промежуточный статус — relaunch ограничен стадией `analysis` (единственный владелец Needs Input, ORCH-066); единственный вход к запуску пайплайна остаётся «To Analyse» (`start_pipeline`). Под `stop_status_enabled=false` гейт инертен (1:1 как раньше). + - **Флаги/наблюдаемость:** `stop_status_enabled` (kill-switch, env `ORCH_STOP_STATUS_ENABLED`) + `stop_status_repos` (CSV, пусто → все репо); leaf `src/cancel.py` (`applies`/`in_critical_window`/`snapshot`, never-raise); read-only блок `stop` в `GET /queue`; лог + Telegram (кликабельный номер) + Plane-коммент + `update_task_tracker`. Аддитивные идемпотентные миграции (`_ensure_column` для `cancelled_at`/`cancel_requested_at`). **Инфра-предусловие:** создать статус **STOP** с группой `cancelled` на доске Plane проекта ORCH (его отсутствие = fail-safe no-op). + - Тесты: `tests/test_stop_status.py` (TC-01..TC-14 + D7-кейсы, 26 кейсов; SIGTERM/git/gitea замоканы — ни один тест не шлёт сигнал/не трогает сеть); обновлены анти-регресс-тесты STAGE_TRANSITIONS 5 прошлых задач (добавлен терминал-сток `cancelled`); полный регресс `tests/` зелёный (1345). Документация: `docs/architecture/README.md` (статус «реализовано» + блок `/queue` + раздел БД), `CLAUDE.md`, `README.md`, `.env.example`. ADR: `docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md`, сквозной `docs/architecture/adr/adr-0026-stop-cancel-task.md`. Откат: `ORCH_STOP_STATUS_ENABLED=false` (аддитивные колонки/терминал-набор инертны при отсутствии отменённых задач). - **Build-cache-pruner: авто-prune docker build cache на mva154** (ORCH-062, `feat`): новый фоновый daemon-поток `src/build_cache_pruner.py` (каркас `disk_watchdog`) — «вторая половина» disk-watchdog (ORCH-063): **watchdog сигналит — pruner убирает**. Устраняет корень инцидента 07.06.2026 (docker build cache ≈11 ГБ → диск mva154 100% → падение self-hosting-конвейера всех проектов) **автоматически, без оператора**. **Аддитивно, never-raise:** `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/`_parse_*`/`src/stage_engine.py`/схема БД — **не тронуты**, новой миграции нет (состояние last-run/last-result — in-memory, best-effort). - **Периодическая уборка (FR-1/AC-1):** каждые `build_cache_prune_interval_s` (дефолт **21600с = 6ч**) тик выполняет **строго `docker builder prune -f --filter until=`** (BuildKit GC). Анти-частота — pure-функция `decide_prune(prev_run_ts, now, interval_s)` (юнит-тестируема без потока/таймера, время инъецируется). Дефолт `until=24h` удерживает тёплый недавний кэш (BR-2/AC-2); `-a/--all` (`build_cache_prune_all`, дефолт `False`) — **только в паре** с возрастным фильтром. - **Self-hosting безопасность (FR-3/AC-3):** команда затрагивает **только** build cache — **нет** `docker image prune`/`docker system prune`, удаления образов/контейнеров запущенных сервисов, остановки/рестарта контейнеров; прод-контейнер `orchestrator` **никогда** не рестартится. Уборка исполняется **на хосте через ssh** (`deploy_ssh_user@deploy_ssh_host`, тот же канал, что `image_freshness`/`self_deploy` — в образе нет docker CLI). Нет ssh-таргета → тик no-op (наблюдаемо в `status().last_error`). diff --git a/CLAUDE.md b/CLAUDE.md index 8c61b44..0d79278 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,6 +111,39 @@ created → analysis → architecture → development → review → testing → Детали — `docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md`, `docs/architecture/adr/adr-0018-auto-label-gates.md`. +## Отмена задачи: статус STOP (ORCH-090) +Выделенный Plane-статус **STOP** — операторская кнопка «отменить + сбросить» задачу. Вводит +**новое системное терминальное состояние `cancelled`** (стадия `tasks.stage='cancelled'` + job-исход +`jobs.status='cancelled'`), равноправное `done`. Логический ключ `stop` — **fail-closed** (нет в +`_DEFAULT_STATES`, по образцу `confirm_deploy`/ORCH-059): доска без статуса STOP → ветка не +активируется. Маршрут `handle_issue_updated → handle_stop → stage_engine.cancel_task`: +- **Полный сброс** (вне критичного окна): graceful SIGTERM активного агента (`launcher.stop_process`, + переиспользует каскад `_watchdog`), все job'ы → терминальный `cancelled` (не реквью'ятся: + `claim_next_job` берёт только `queued`, reaper/worker сверяют терминал задачи — TR-2), удаление + worktree + **рабочей** Gitea-ветки (`gitea.delete_remote_branch`, **никогда** `main`, без + force-push), durable `stage='cancelled'` + **тумбстон** натуральных ключей (`plane_id`/ + `work_item_id`/`plane_issue_id` → суффикс `#cancelled-`; ADR-001 D4 уточнён: тумбстонится и + `plane_issue_id`, т.к. `get_task_by_plane_id`/`create_task_atomic` матчат по нему — иначе re-create + коллизирует; исходный UUID парсится из суффикса для аудита). Docs-артефакты (`01..17`) сохраняются. +- **STOP в критичном окне merge/deploy** (ADR-001 D7): `cancel.in_critical_window` (INITIATED-sentinel + self-deploy ORCH-036 / держание merge-lease ORCH-043) → **отложенная** отмена: `tasks.cancel_requested_at`, + снимаются только `queued` job'ы (running-актор деплоя/мержа не трогается), алерт; детерминированный + finalizer (`run_deploy_finalizer`) доводит необратимый шаг до честного исхода и применяет отмену + (`force=True`). STOP **никогда** не трогает `main`/force-push/прод-контейнер/detached-процесс (NFR-3). +- **Кросс-каттинг (adr-0026):** предикат «задача терминальна» расширен `{done}` → `{done, cancelled}` + в `serial_gate`/`task_deps`/`stages.py`-сток (иначе отменённая задача заклинит очередь репо); + reconciler-терминал-скип уже знал `cancelled` (ORCH-086). `STAGE_TRANSITIONS` exit-гейты рёбер / + `QG_CHECKS` / `check_*` — **не тронуты** (`cancelled` — сток, не ребро). +- **Дыра релонча закрыта (D6):** relaunch агента в `handle_status_start` ограничен стадией `analysis` + (единственный владелец Needs Input, ORCH-066); ручной перевод существующей задачи в иной промежуточный + статус больше не релончит середину пайплайна. Запуск пайплайна — только «To Analyse» → `start_pipeline`. +- Флаги `stop_status_enabled` (kill-switch; `False` → всё инертно, нулевая регрессия) / `stop_status_repos` + (CSV; пусто → все репо). Leaf `src/cancel.py` (never-raise). Read-only блок `stop` в `GET /queue`. + Аддитивные колонки `tasks.cancelled_at`/`cancel_requested_at` (`_ensure_column`). **Инфра-предусловие:** + создать статус **STOP** с группой `cancelled` на доске ORCH (его отсутствие = fail-safe no-op). Детали — + `docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md`, + `docs/architecture/adr/adr-0026-stop-cancel-task.md`. + ## Конвенции - Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`) - Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug` diff --git a/README.md b/README.md index 0b116c4..780c276 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,8 @@ uvicorn src.main:app --reload --port 8500 | `ORCH_RECONCILE_NOTIFY_UNBLOCK` | Telegram при разблокировке застрявшей задачи | `true` | | `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED` | F-1 Guard 2 (ORCH-060): пропуск задач в Plane-статусе Blocked / Needs Input; `false` глушит только сетевой Guard 2 (Guard 1 escalated всегда активен) | `true` | | `ORCH_QG0_TITLE_MAX` | Верхний лимит длины заголовка QG-0 (вход `_qg0_errors`); невалидное/пустое значение → дефолт (ORCH-069) | `200` | +| `ORCH_STOP_STATUS_ENABLED` | Kill-switch отмены задачи по Plane-статусу **STOP** + закрытия дыры релонча (ORCH-090); `false` → поведение 1:1 как до ORCH-090 | `true` | +| `ORCH_STOP_STATUS_REPOS` | CSV область репо для STOP-отмены; пусто = все репо (ORCH-090) | `""` | ## Очередь задач (ORCH-1 / F-2b) @@ -154,7 +156,30 @@ Webhook-хэндлеры больше не спавнят claude-агентов - **Ретраи.** Упавший job (exit≠0) ретраится пока `attempts < max_attempts`, потом `failed` + Telegram-нотификация. -Статусы job: `queued → running → done | failed`. Наблюдаемость — через `GET /queue`. +Статусы job: `queued → running → done | failed`; **`cancelled`** — терминальный +исход STOP-отмены (ORCH-090), нигде не реквью'ится. Наблюдаемость — через `GET /queue`. + +## Отмена задачи: статус STOP (ORCH-090) + +Перевод задачи в выделенный Plane-статус **STOP** отменяет её: оркестратор +останавливает активного агента (graceful SIGTERM-каскад), снимает все job'ы +(терминальный `cancelled`, без авто-requeue), удаляет worktree и **рабочую** +ветку в Gitea (**никогда** `main`, без force-push), сбрасывает прогресс в +durable-терминал `tasks.stage='cancelled'` и тумбстонит натуральные ключи +(`#cancelled-`), чтобы повторный «To Analyse» создал задачу **с нуля**. +Docs-артефакты (`01..17`) сохраняются. STOP во время критичного шага merge/deploy +— **откладывается** до его честного завершения (никакого half-merge / рестарта +прода). Параллельно закрыта «дыра релонча»: ручной перевод в промежуточный рабочий +статус больше не релончит агента — единственный вход к запуску пайплайна остаётся +«To Analyse» (релонч агента сменой статуса разрешён только на стадии `analysis` — +владельце Needs Input). Всё под kill-switch `ORCH_STOP_STATUS_ENABLED`, аддитивно, +never-raise. Наблюдаемость — блок `stop` в `GET /queue`. Деталь — `docs/work-items/ +ORCH-090/06-adr/ADR-001-stop-cancel-task.md` + сквозной +`docs/architecture/adr/adr-0026-stop-cancel-task.md`. + +> **Инфра-предусловие:** на доске Plane проекта ORCH создать статус **«STOP»** с +> группой `cancelled`. До создания статуса фича в fail-safe (нет UUID → ветка STOP +> не активируется). **Resilience-слой:** дешёвый preflight (CLI/net, кэш, без токенов) гейтит claim; 429/overload детектится по логу (transient vs permanent), transient ретраится с diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 8d89675..facca2a 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -278,7 +278,7 @@ Phase A ждёт ручного `Confirm Deploy`, ORCH-059). ORCH-089 снима `docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md`, `docs/work-items/ORCH-089/07-infra-requirements.md`. -### STOP / отмена задачи: терминал `cancelled` + закрытие дыры релонча (ORCH-090 — design) +### STOP / отмена задачи: терминал `cancelled` + закрытие дыры релонча (ORCH-090 — реализовано) До ORCH-090 не было штатного способа отменить задачу (ручная хирургия по БД/процессам) и существовала **дыра релонча**: `handle_status_start` при существующей задаче без активного job @@ -298,9 +298,16 @@ Phase A ждёт ручного `Confirm Deploy`, ORCH-059). ORCH-089 снима - **Каскад отмены:** graceful SIGTERM активному агенту (переиспользование каскада `launcher._watchdog` по `jobs.pid`); `cancel_jobs_for_task` (queued/running → `cancelled`, не реквью'ятся); снятие таймеров/мониторов (brd-clock, post-deploy monitor, defer'ы); - `remove_worktree` + never-raise удаление **только feature-ветки** Gitea (`main` неприкосновенен, - без force-push); **тумбстон** `plane_id`/`work_item_id` (`#cancelled-`) → повторный - «To Analyse» создаёт задачу с нуля; docs-артефакты (`01..17`) сохраняются. + `remove_worktree` + never-raise удаление **только feature-ветки** Gitea (`gitea.delete_remote_branch`; + `main`/`master` неприкосновенны — явный гард; без force-push); **тумбстон** `plane_id`/`work_item_id`/ + **`plane_issue_id`** (суффикс `#cancelled-`) → `get_task_by_plane_id` возвращает None → повторный + «To Analyse» создаёт задачу с нуля; docs-артефакты (`01..17`) сохраняются. Аддитивные колонки + `tasks.cancelled_at`/`cancel_requested_at` (`_ensure_column`). + > **Уточнение ADR-001 D4 (при реализации):** ADR предлагал сохранить `plane_issue_id` нетронутым, но + > `get_task_by_plane_id`/`create_task_atomic` матчат по `plane_id OR plane_issue_id` — нетумбстоненный + > `plane_issue_id` оставил бы отменённую строку «находимой» и заблокировал бы re-create (BR-3/TR-4). + > Поэтому он тоже тумбстонится; исходный UUID (== исходный `plane_id` во всех путях создания) парсится + > из детерминированного суффикса для аудита. - **Безопасное прерывание merge/deploy:** STOP в критическом окне (self-deploy `INITIATED`-sentinel ORCH-036, держание merge-lease ORCH-043/071) → **отложенная отмена** (durable `cancel_requested_at`, отмена только `queued`-job'ов, алерт); необратимый шаг доводится до @@ -782,9 +789,9 @@ Monitoring after Deploy → Done ## База данных (SQLite) - `events` — входящие вебхуки (дедуп) -- `tasks` — задачи и их стадии +- `tasks` — задачи и их стадии; колонки `cancelled_at`/`cancel_requested_at` (ORCH-090) — durable-метки STOP-отмены (вторая — отложенная отмена в критичном окне merge/deploy). Терминальная стадия `cancelled` (сток, параллельно `done`); натуральные ключи отменённой строки тумбстонятся суффиксом `#cancelled-` (`plane_id`/`work_item_id`/`plane_issue_id`) - `agent_runs` — запуски агентов (run_id, usage, cost) -- `jobs` — очередь задач (ORCH-1); колонка `pid` (ORCH-065) — pid агентского процесса для liveness-детекции зомби job-reaper'ом +- `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» - `repo_freeze` — durable per-repo rollback-freeze (ORCH-088, FR-5): `(id, repo, frozen_at, reason, work_item_id, cleared_at)`, аддитивная append-only; активный freeze ⇔ строка репо с `cleared_at IS NULL`. Выставляется post-deploy `DEGRADED` (`set_repo_freeze`), снимается вручную (`POST /serial-gate/unfreeze` → `cleared_at=now`). Гейтит serial-claim безусловно (деградировавшая задача уже `done`) @@ -796,7 +803,7 @@ Monitoring after Deploy → Done |--------|------|----------| | GET | `/health` | health check | | GET | `/status` | активные задачи (stage != 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) + последние jobs | +| 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) + последние jobs | | POST | `/serial-gate/unfreeze` | ORCH-088 (FR-5): ручное снятие per-repo rollback-freeze (query/body `repo=`) → `{ok, repo, cleared, frozen}`; идемпотентно. Альтернатива — `UPDATE repo_freeze SET cleared_at=datetime('now') WHERE repo=? AND cleared_at IS NULL` | | POST | `/webhook/plane` | Plane webhook | | POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) | diff --git a/src/agents/launcher.py b/src/agents/launcher.py index 3d4f796..15eb41d 100644 --- a/src/agents/launcher.py +++ b/src/agents/launcher.py @@ -679,17 +679,47 @@ class AgentLauncher: if timeout is None: timeout = self._resolve_timeout(agent) time.sleep(timeout) + # ORCH-090: the SIGTERM->grace->SIGKILL cascade is now a reusable helper + # (stop_process) shared with the STOP-cancellation path. The timeout + # watchdog just sleeps the timeout, then drives the cascade. + logger.warning( + f"Agent run_id={run_id} exceeded {timeout}s timeout (pid={pid})" + ) + self.stop_process(pid, run_id, reason=f"timeout>{timeout}s") + def stop_process(self, pid: int, run_id: int | None, *, reason: str = "stop") -> bool: + """ORCH-7 / ORCH-090 (ADR-001 D2): graceful SIGTERM->grace->SIGKILL cascade. + + Extracted from ``_watchdog`` so the STOP-cancellation path + (``stage_engine.cancel_task``) stops an active agent through the SAME + graceful cascade instead of a new "dirty" kill (AC-1). Send SIGTERM, give + the process up to ``settings.agent_kill_grace_seconds`` to flush and exit, + SIGKILL only if it is still alive after the grace; stamp ``agent_runs`` + exit_code=-9 via ``_record_kill`` whenever a kill actually happened. + + never-raise; ``ProcessLookupError`` is tolerated at every step (the process + may already be gone). Returns True iff a SIGTERM was delivered to a live + process; False when the process was already gone (no record — the monitor's + ``proc.wait()`` owns that exit). + """ + if pid is None: + return False # Phase 1: SIGTERM (graceful). If the process is already gone, we're done. try: os.kill(pid, signal.SIGTERM) logger.warning( - f"Agent run_id={run_id} exceeded {timeout}s timeout: sent SIGTERM " - f"(pid={pid}), grace={settings.agent_kill_grace_seconds}s" + f"stop_process ({reason}): sent SIGTERM to pid={pid} " + f"(run_id={run_id}), grace={settings.agent_kill_grace_seconds}s" ) except ProcessLookupError: - logger.info(f"Agent run_id={run_id} already exited before SIGTERM") - return # nothing to record: the monitor's proc.wait() owns the exit + logger.info( + f"stop_process ({reason}): pid={pid} already exited " + f"(run_id={run_id}); nothing to record" + ) + return False + except Exception as e: # noqa: BLE001 - never-raise + logger.warning(f"stop_process SIGTERM error pid={pid}: {e}") + return False # Phase 2: poll for graceful exit within the grace window. grace = settings.agent_kill_grace_seconds @@ -702,21 +732,27 @@ class AgentLauncher: os.kill(pid, 0) # signal 0 = liveness probe, does not kill except ProcessLookupError: logger.info( - f"Agent run_id={run_id} exited gracefully after SIGTERM " - f"({waited:.1f}s); no SIGKILL needed" + f"stop_process ({reason}): pid={pid} exited gracefully after " + f"SIGTERM ({waited:.1f}s); no SIGKILL needed" ) self._record_kill(run_id) - return + return True + except Exception: # noqa: BLE001 - probe error -> escalate to SIGKILL + break # Phase 3: still alive -> hard SIGKILL. try: os.kill(pid, signal.SIGKILL) logger.warning( - f"Agent run_id={run_id} did not exit within {grace}s grace: sent SIGKILL" + f"stop_process ({reason}): pid={pid} did not exit within {grace}s " + f"grace: sent SIGKILL" ) except ProcessLookupError: - logger.info(f"Agent run_id={run_id} exited just before SIGKILL") + logger.info(f"stop_process ({reason}): pid={pid} exited just before SIGKILL") + except Exception as e: # noqa: BLE001 - never-raise + logger.warning(f"stop_process SIGKILL error pid={pid}: {e}") self._record_kill(run_id) + return True @staticmethod def _record_kill(run_id: int): diff --git a/src/cancel.py b/src/cancel.py new file mode 100644 index 0000000..f30256d --- /dev/null +++ b/src/cancel.py @@ -0,0 +1,144 @@ +"""ORCH-090 (ADR-001 D9 / adr-0026): STOP-cancellation leaf — pure decision logic. + +Leaf module mirroring ``src/serial_gate.py`` / ``src/labels.py``: pure, +unit-testable, never-raise functions over config + the existing DB / deploy-state. +Module-level imports are limited to ``config`` (and ``re``); the critical-window +probe lazily imports ``self_deploy`` / ``merge_gate`` so a cycle can never form and +an import failure degrades safely. + +What it answers: + * ``applies(repo)`` — is STOP-cancellation REAL for this repo? + * ``in_critical_window(task)``— is the task inside an irreversible merge/deploy + step where cancellation must be DEFERRED (ADR-001 D7) instead of applied now? + * ``snapshot()`` — read-only summary for ``GET /queue`` (AC-10). + +The ORCHESTRATION of a cancellation (SIGTERM, cancel-jobs, worktree/branch +cleanup, key tombstone, notifications) lives in ``stage_engine.cancel_task`` — this +leaf only decides, it never mutates. + +never-raise contract (self-hosting safety): every public function degrades +conservatively. ``applies`` -> False on error (gate inert, the kill-switch-off +default). ``in_critical_window`` -> True on doubt (fail-CLOSED: when we cannot +confirm we are OUTSIDE a critical window, DEFER cancellation rather than risk +tearing a half-merge / detached prod deploy, NFR-3 / TR-3). +""" +from __future__ import annotations + +import logging +import re + +from .config import settings + +logger = logging.getLogger("orchestrator.cancel") + +# Repo tokens in the CSV scope must match this (mirrors serial_gate._REPO_TOKEN). +_REPO_TOKEN = re.compile(r"^[A-Za-z0-9._-]+$") + + +def _scope_repos() -> set[str]: + """Sanitised set of in-scope repo tokens from ``stop_status_repos`` (CSV). + + Empty/blank CSV -> empty set, meaning "apply to ALL repos" (D9). Invalid tokens + (regex miss) are dropped. Never raises. + """ + try: + raw = (settings.stop_status_repos or "").strip() + except Exception: # noqa: BLE001 + return set() + if not raw: + return set() + out: set[str] = set() + for tok in raw.split(","): + t = tok.strip() + if t and _REPO_TOKEN.match(t): + out.add(t) + elif t: + logger.warning("cancel: dropping invalid repo token %r from CSV", t) + return out + + +def applies(repo: str) -> bool: + """Whether STOP-cancellation is REAL for this repo (D9 / AC-8). + + * ``stop_status_enabled=False`` -> always False (kill-switch; STOP handling and + the relaunch-hole gate are 1:1 as before ORCH-090). + * ``stop_status_repos`` (CSV) non-empty -> real only for listed repos. + * empty CSV -> real for ALL repos (cancellation is meaningful for enduro too). + Never raises -> False on error (degrade to "inert", matching kill-switch off). + """ + try: + if not getattr(settings, "stop_status_enabled", False): + return False + scope = _scope_repos() + if scope: + return (repo or "").strip() in scope + return True + except Exception as e: # noqa: BLE001 - never-raise + logger.warning("cancel.applies error for %s: %s", repo, e) + return False + + +def in_critical_window(task: dict) -> bool: + """Is the task inside an irreversible merge/deploy step (ADR-001 D7 / AC-7)? + + A STOP that lands here must NOT tear the step apart (half-merge / detached prod + deploy / dead prod container, NFR-3). Two markers (existing, no new state): + * self-deploy Phase B initiated — the ``INITIATED`` sentinel in + ``/.deploy-state-//`` (ORCH-036); + * the task currently HOLDS the per-repo merge-lease + ``/.merge-lease-.json`` (ORCH-043), holder branch == task + branch. + + fail-CLOSED (TR-3): any error/uncertainty -> True (DEFER cancellation). Outside + the window -> False (apply the full reset immediately). + """ + if not task: + return False + repo = task.get("repo") + work_item_id = task.get("work_item_id") + branch = task.get("branch") + try: + from . import self_deploy + if self_deploy.has_marker(repo, work_item_id, self_deploy.INITIATED): + return True + except Exception as e: # noqa: BLE001 - fail-CLOSED on doubt + logger.warning("cancel.in_critical_window self_deploy probe error: %s", e) + return True + try: + from . import merge_gate + holder = merge_gate.current_lease_holder(repo) + if holder and branch and holder == branch: + return True + except Exception as e: # noqa: BLE001 - fail-CLOSED on doubt + logger.warning("cancel.in_critical_window merge-lease probe error: %s", e) + return True + return False + + +def snapshot() -> dict: + """Read-only STOP-cancellation summary for GET /queue (AC-10). + + Additive block; existing /queue keys are untouched. never-raise -> a minimal + dict with the flags on error. + """ + try: + enabled = bool(getattr(settings, "stop_status_enabled", False)) + except Exception: # noqa: BLE001 + enabled = False + try: + repos_cfg = getattr(settings, "stop_status_repos", "") or "" + except Exception: # noqa: BLE001 + repos_cfg = "" + try: + from . import db + stats = db.cancelled_tasks_snapshot(10) + except Exception as e: # noqa: BLE001 - never-raise + logger.warning("cancel.snapshot error: %s", e) + stats = {"count": 0, "pending": 0, "recent": []} + return { + "enabled": enabled, + "repos": repos_cfg, + "cancelled_count": stats.get("count", 0), + "deferred_pending": stats.get("pending", 0), + "recent": stats.get("recent", []), + } diff --git a/src/config.py b/src/config.py index 8080608..bca46f8 100644 --- a/src/config.py +++ b/src/config.py @@ -605,6 +605,25 @@ class Settings(BaseSettings): serial_gate_repos: str = "" serial_gate_freeze_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, + # absent from _DEFAULT_STATES) routes to a cancel handler that drives the task to + # the new system-terminal state `cancelled` (stage + durable). Additive, + # never-raise, restart-safe; STAGE_TRANSITIONS / QG_CHECKS / check_* / existing + # status semantics are NOT touched. See + # docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md and the cross-cutting + # docs/architecture/adr/adr-0026-stop-cancel-task.md. + # stop_status_enabled -> kill-switch (env ORCH_STOP_STATUS_ENABLED). False -> + # STOP handling AND the relaunch-hole gate are inert + # (behaviour strictly as before ORCH-090 — zero + # regression, AC-8). + # stop_status_repos -> CSV scope (env ORCH_STOP_STATUS_REPOS). Empty -> applies + # to ALL repos (cancellation is meaningful for enduro too); + # non-empty -> only the listed repos. Tokens are sanitised + # (^[A-Za-z0-9._-]+$) by the cancel leaf. + stop_status_enabled: bool = True + stop_status_repos: str = "" + # ORCH-073 (ADR-001 Р-4): main-integrity regression guard. After the merge-verify # under-gate confirms the deployed SHA is an ancestor of origin/main (FR-1), a # secondary deterministic (no-LLM) guard checks that a declarative set of markers diff --git a/src/db.py b/src/db.py index 05bec92..513c712 100644 --- a/src/db.py +++ b/src/db.py @@ -59,7 +59,7 @@ def init_db(): repo TEXT NOT NULL, task_id INTEGER, -- FK tasks.id (nullable) task_content TEXT, -- written to the agent task_file - status TEXT NOT NULL DEFAULT 'queued', -- queued|running|done|failed + status TEXT NOT NULL DEFAULT 'queued', -- queued|running|done|failed|cancelled (ORCH-090: cancelled is a terminal outcome, never requeued) attempts INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 2, run_id INTEGER, -- agent_runs.id once started @@ -129,6 +129,17 @@ def init_db(): # tracker can show "твоё время" without recomputing from activity history. _ensure_column(conn, "tasks", "brd_review_started_at", "TEXT") _ensure_column(conn, "tasks", "brd_review_ended_at", "TEXT") + # ORCH-090 (08-data-requirements.md): STOP-cancellation durable markers. Both are + # additive, idempotent (_ensure_column is a no-op once present) -> safe on the live + # shared prod DB (enduro untouched). The durable terminal itself is tasks.stage= + # 'cancelled' (already understood by the reconciler terminal-skip); these columns + # are audit/observability + the deferred-cancel signal. + # cancelled_at -> timestamp the task was cancelled (NULL otherwise). + # cancel_requested_at -> STOP arrived inside a critical merge/deploy window + # (ADR-001 D7): cancellation is DEFERRED until the + # irreversible step finishes honestly, then applied. + _ensure_column(conn, "tasks", "cancelled_at", "TEXT") + _ensure_column(conn, "tasks", "cancel_requested_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 @@ -231,6 +242,13 @@ def get_active_tasks_for_reconcile() -> list[dict]: ``age_s`` = seconds since ``tasks.updated_at`` (computed in SQL against UTC 'now', matching how ``update_task_stage`` stamps ``updated_at``). The reconciler applies the per-stage grace and active-job guard on top. + + ORCH-090 (adr-0026): a ``cancelled`` task is DELIBERATELY still returned here + and skipped by the reconciler's own terminal-skip (``stage in + ('done','cancelled')``, ORCH-086 D2) — narrowing the query to exclude + ``cancelled`` would lose the observability skip-counter increment that ORCH-086 + relies on. The terminal set is harmonised in the *scheduler* predicates + (serial_gate / task_deps), not here. """ conn = get_db() try: @@ -605,7 +623,9 @@ def claim_next_job() -> dict | None: dep_gate = ( "AND NOT EXISTS (" " SELECT 1 FROM job_deps d JOIN tasks t ON t.id = d.depends_on_task_id " - " WHERE d.task_id = jobs.task_id AND t.stage != 'done'" + # ORCH-090 (adr-0026): a cancelled predecessor is TERMINAL -> the + # dependent must NOT wait on it forever. Terminal set = {done,cancelled}. + " WHERE d.task_id = jobs.task_id AND t.stage NOT IN ('done','cancelled')" ") " ) # ORCH-088 (FR-1, ADR-001 D1): per-repo serial gate. An analyst-job of a NEW @@ -683,11 +703,11 @@ def mark_job( run_id: int | None = None, error: str | None = None, ): - """Update a job's status (queued|running|done|failed). + """Update a job's status (queued|running|done|failed|cancelled). - run_id (optional): link to the agent_runs row that executed this job. - error (optional): last error message (for failed/retry). - - 'done'/'failed' also stamp finished_at. + - 'done'/'failed'/'cancelled' (ORCH-090) also stamp finished_at. - 'queued' (requeue for retry) clears started_at/finished_at so the next claim treats it as fresh. """ @@ -700,7 +720,7 @@ def mark_job( if error is not None: sets.append("error = ?") params.append(error) - if status in ("done", "failed"): + if status in ("done", "failed", "cancelled"): sets.append("finished_at = datetime('now')") elif status == "queued": sets.append("started_at = NULL") @@ -728,6 +748,178 @@ def has_active_job_for_task(task_id: int) -> bool: return row is not None +# --------------------------------------------------------------------------- +# ORCH-090: STOP-cancellation helpers (task + jobs terminal state) +# --------------------------------------------------------------------------- + +def get_task(task_id: int) -> dict | None: + """Fetch a single task row by id (None when absent).""" + conn = get_db() + try: + row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone() + finally: + conn.close() + return dict(row) if row else None + + +def get_active_jobs_for_task(task_id: int) -> list[dict]: + """ORCH-090: queued/running jobs of a task (for STOP — stop agent + cancel). + + Returns the full job rows (incl. ``pid`` / ``run_id`` / ``status``) so the + cancel orchestrator can SIGTERM the running agent by ``jobs.pid`` and then flip + every job to the terminal ``cancelled`` outcome. + """ + conn = get_db() + try: + rows = conn.execute( + "SELECT * FROM jobs WHERE task_id = ? AND status IN ('queued','running') " + "ORDER BY id", + (task_id,), + ).fetchall() + finally: + conn.close() + return [dict(r) for r in rows] + + +def cancel_jobs_for_task(task_id: int, only_queued: bool = False) -> int: + """ORCH-090 (ADR-001 D3): flip a task's jobs to the terminal ``cancelled`` outcome. + + Guarded UPDATE over ``status IN ('queued','running')`` (or only ``'queued'`` when + ``only_queued`` — the deferred-cancel path inside a critical merge/deploy window, + D7, which must NOT cancel the still-running deploy/merge actor). ``cancelled`` is + never requeued: ``claim_next_job`` only selects ``status='queued'`` and the reaper + / worker check the task's terminal stage before any requeue. Returns the number of + jobs cancelled. never-raise -> 0 on error. + """ + statuses = "('queued')" if only_queued else "('queued','running')" + try: + conn = get_db() + try: + cur = conn.execute( + f"UPDATE jobs SET status='cancelled', finished_at=datetime('now') " + f"WHERE task_id = ? AND status IN {statuses}", + (task_id,), + ) + conn.commit() + return cur.rowcount or 0 + finally: + conn.close() + except Exception: + return 0 + + +def mark_task_cancelled(task_id: int) -> bool: + """ORCH-090 (ADR-001 D4): durable terminal + natural-key tombstone for a task. + + Atomically (single UPDATE): + * ``stage='cancelled'`` (durable terminal, understood by the reconciler skip); + * ``cancelled_at=now``, ``cancel_requested_at=NULL`` (clear any deferred flag); + * TOMBSTONE the natural keys so a later "To Analyse" re-creates the task FROM + SCRATCH: ``plane_id`` / ``work_item_id`` / ``plane_issue_id`` get a + deterministic ``#cancelled-`` suffix -> ``get_task_by_plane_id`` returns + None and the anti-dup / uniqueness guards no longer collide. The row is NOT + deleted (durable audit). + + ADR-001 D4 refinement (ORCH-090): the ADR proposed keeping ``plane_issue_id`` + untouched for audit, but ``get_task_by_plane_id`` / ``create_task_atomic`` match + on ``plane_id OR plane_issue_id`` — leaving ``plane_issue_id`` matchable would + keep the cancelled row "findable" and BLOCK the clean-slate re-create (BR-3 / + TR-4). We therefore suffix it too; the ``#cancelled-`` tag is deterministic + and parseable, so the original Plane issue UUID (== the original ``plane_id`` in + every create path) is still fully recoverable for audit. + + Idempotent-safe: the suffix is only appended when not already present (a repeat + STOP on an already-cancelled row does not double-suffix). Returns True iff the + row was updated. never-raise -> False on error. + """ + try: + conn = get_db() + try: + row = conn.execute( + "SELECT plane_id, work_item_id, plane_issue_id FROM tasks WHERE id = ?", + (task_id,), + ).fetchone() + if not row: + return False + suffix = f"#cancelled-{task_id}" + + def _tomb(v): + v = v or "" + return v if suffix in v else f"{v}{suffix}" + + plane_id = _tomb(row["plane_id"]) + work_item_id = _tomb(row["work_item_id"]) + plane_issue_id = _tomb(row["plane_issue_id"]) + conn.execute( + "UPDATE tasks SET stage='cancelled', cancelled_at=datetime('now'), " + "cancel_requested_at=NULL, plane_id=?, work_item_id=?, plane_issue_id=?, " + "updated_at=datetime('now') WHERE id = ?", + (plane_id, work_item_id, plane_issue_id, task_id), + ) + conn.commit() + return True + finally: + conn.close() + except Exception: + return False + + +def set_task_cancel_requested(task_id: int) -> bool: + """ORCH-090 (ADR-001 D7): mark a deferred cancellation (STOP in critical window). + + Idempotent: only stamps ``cancel_requested_at`` the first time. The deterministic + deploy/merge finalizer reads it once the irreversible step completes and then + applies the full cancellation. never-raise -> False on error. + """ + try: + conn = get_db() + try: + conn.execute( + "UPDATE tasks SET cancel_requested_at=datetime('now') " + "WHERE id = ? AND cancel_requested_at IS NULL", + (task_id,), + ) + conn.commit() + return True + finally: + conn.close() + except Exception: + return False + + +def cancelled_tasks_snapshot(limit: int = 10) -> dict: + """ORCH-090 (AC-10): read-only cancellation summary for GET /queue. + + Returns ``{count, pending, recent}`` where ``count`` is the number of cancelled + tasks, ``pending`` the number with a deferred (not-yet-applied) cancellation, and + ``recent`` the last ``limit`` cancelled tasks. never-raise -> minimal dict. + """ + try: + conn = get_db() + try: + count = conn.execute( + "SELECT COUNT(*) FROM tasks WHERE stage='cancelled'" + ).fetchone()[0] + pending = conn.execute( + "SELECT COUNT(*) FROM tasks WHERE cancel_requested_at IS NOT NULL " + "AND stage != 'cancelled'" + ).fetchone()[0] + recent = [ + {"work_item_id": r["work_item_id"], "repo": r["repo"], + "cancelled_at": r["cancelled_at"]} + for r in conn.execute( + "SELECT work_item_id, repo, cancelled_at FROM tasks " + "WHERE stage='cancelled' ORDER BY cancelled_at DESC LIMIT ?", + (limit,), + ).fetchall() + ] + finally: + conn.close() + return {"count": int(count), "pending": int(pending), "recent": recent} + except Exception: + return {"count": 0, "pending": 0, "recent": []} + + def count_running_jobs() -> int: """Number of jobs currently in 'running' status (for max_concurrency).""" conn = get_db() @@ -815,7 +1007,7 @@ def reap_running_job( if error is not None: sets.append("error = ?") params.append(error) - if status in ("done", "failed"): + if status in ("done", "failed", "cancelled"): # ORCH-090: cancelled is terminal sets.append("finished_at = datetime('now')") elif status == "queued": sets.append("started_at = NULL") @@ -948,7 +1140,9 @@ def get_unfinished_dependencies(task_id: int) -> list[dict]: rows = conn.execute( "SELECT t.id AS id, t.work_item_id AS work_item_id, t.stage AS stage " "FROM job_deps d JOIN tasks t ON t.id = d.depends_on_task_id " - "WHERE d.task_id = ? AND t.stage != 'done'", + # ORCH-090 (adr-0026): {done,cancelled} are both terminal -> a + # cancelled predecessor no longer blocks the dependent. + "WHERE d.task_id = ? AND t.stage NOT IN ('done','cancelled')", (task_id,), ).fetchall() finally: diff --git a/src/gitea.py b/src/gitea.py new file mode 100644 index 0000000..ec0d101 --- /dev/null +++ b/src/gitea.py @@ -0,0 +1,65 @@ +"""ORCH-090 (ADR-001 D8 / adr-0026): minimal Gitea branch helpers. + +Leaf module — a single never-raise helper used by the STOP-cancellation path to +delete a cancelled task's REMOTE feature branch. Deliberately tiny and dependency +-light (only ``config`` + ``httpx``) so it can be imported from the stage engine +without cycles. + +Self-hosting safety (NFR-3): this helper deletes ONLY the named feature branch +via the Gitea API. It NEVER touches ``main`` (a guard rejects it outright) and +NEVER force-pushes — there is no push path here at all. +""" +import logging + +import httpx + +from .config import settings + +logger = logging.getLogger("orchestrator.gitea") + +# Branches that must never be deleted by an automated cancel (self-hosting safety). +_PROTECTED_BRANCHES = {"main", "master"} + + +def delete_remote_branch(repo: str, branch: str) -> bool: + """Delete a remote feature branch in Gitea (never-raise). + + ``DELETE /api/v1/repos/{owner}/{repo}/branches/{branch}``. Used by + ``stage_engine.cancel_task`` to reset a cancelled task's progress (D8). A 404 + (branch already gone) is treated as success — the goal state (branch absent) is + reached. Returns True iff the branch is confirmed absent after the call. + + Guards: + * empty repo/branch -> no-op (False); + * a protected branch (``main``/``master``) -> refused with an error log + (NFR-3: STOP must never delete ``main``). + Any network/API error is logged and swallowed (the worktree is cleaned locally + regardless); returns False so the caller can note a best-effort miss. + """ + if not repo or not branch: + return False + if branch.strip().lower() in _PROTECTED_BRANCHES: + logger.error( + "delete_remote_branch REFUSED for protected branch %r in %s (self-hosting safety)", + branch, repo, + ) + return False + owner = settings.gitea_owner + url = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/branches/{branch}" + headers = {"Authorization": f"token {settings.gitea_token}"} + try: + resp = httpx.delete(url, headers=headers, timeout=10) + if resp.status_code in (204, 200): + logger.info("Deleted remote branch %s in %s/%s", branch, owner, repo) + return True + if resp.status_code == 404: + logger.info("Remote branch %s already absent in %s/%s", branch, owner, repo) + return True + logger.warning( + "delete_remote_branch %s in %s/%s returned %s: %s", + branch, owner, repo, resp.status_code, resp.text[:200], + ) + return False + except Exception as e: # noqa: BLE001 - never-raise + logger.warning("delete_remote_branch error for %s/%s/%s: %s", owner, repo, branch, e) + return False diff --git a/src/job_reaper.py b/src/job_reaper.py index f71928c..1546d48 100644 --- a/src/job_reaper.py +++ b/src/job_reaper.py @@ -325,6 +325,16 @@ class JobReaper: attempts = int(job.get("attempts") or 0) max_attempts = int(job.get("max_attempts") or 2) err = f"reaped: {reason} (run_id={run_id})" + # ORCH-090 (adr-0026 / TR-2): the source of truth for "do not revive" is the + # task's TERMINAL stage, not the job status. If the task is already terminal + # ({done,cancelled}) — e.g. STOP flipped it to 'cancelled' while this job was + # still 'running' (dead pid) — flip the job to the terminal 'cancelled' + # outcome instead of requeueing it (closes the SIGTERM/reaper requeue race). + _branch, _stage, _wid = self._task_meta(job) + if _stage in ("done", "cancelled"): + if reap_running_job(job_id, "cancelled", run_id=run_id, error=err): + self._note_reap(job, "cancelled", reason=f"{reason} (task terminal={_stage})") + return if attempts < max_attempts: if reap_running_job(job_id, "queued", run_id=run_id, error=err): self._note_reap(job, "queued", reason=reason) diff --git a/src/main.py b/src/main.py index 48b484f..fdd9fa2 100644 --- a/src/main.py +++ b/src/main.py @@ -171,6 +171,7 @@ async def queue(): from . import task_deps from . import serial_gate from . import labels + from . import cancel from .disk_watchdog import disk_watchdog from .build_cache_pruner import build_cache_pruner return { @@ -191,6 +192,10 @@ async def queue(): # ORCH-089 (D7): auto-mode-by-label observability (read-only) — kill-switch, # label names, scope. Additive block. "auto_labels": labels.snapshot(), + # ORCH-090 (AC-10): STOP-cancellation observability (read-only) — kill-switch, + # repo scope, cancelled/deferred counts, recent cancellations. Additive block; + # never-raise. + "stop": cancel.snapshot(), # ORCH-063 (FR-6 / AC-7): disk-watchdog observability (read-only) — # enabled, threshold, interval, last measurement per host-path. Additive # block; never-raise (status() returns {"enabled": ...} minimum on error). diff --git a/src/merge_gate.py b/src/merge_gate.py index 2cb78ff..1341016 100644 --- a/src/merge_gate.py +++ b/src/merge_gate.py @@ -340,6 +340,21 @@ def release_merge_lease(repo: str, branch: str | None = None) -> None: logger.warning("merge-lease release error for %s: %s", repo, e) +def current_lease_holder(repo: str) -> str | None: + """ORCH-090: branch currently holding the per-repo merge-lease, or None. + + Read-only helper used by ``cancel.in_critical_window`` to decide whether a STOP + must be DEFERRED (the task is mid-merge). Never raises -> None on missing/corrupt + lease or any error (the caller treats an error as fail-CLOSED itself). + """ + try: + existing = _read_lease(_lease_path(repo)) + return existing.get("branch") if existing else None + except Exception as e: # noqa: BLE001 - never-raise + logger.warning("current_lease_holder error for %s: %s", repo, e) + return None + + # --------------------------------------------------------------------------- # ORCH-065: proactive stale/dead merge-lease reclaim (Problem B) # --------------------------------------------------------------------------- diff --git a/src/plane_sync.py b/src/plane_sync.py index 0bd2fd2..88f651e 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -148,6 +148,13 @@ _PLANE_NAME_TO_KEY: dict[str, str] = { # this board status (enduro / API fallback) fail-closed — no UUID, no # confirm-deploy branch, no KeyError (accessed via .get). "Confirm Deploy": "confirm_deploy", + # ORCH-090: dedicated operator "STOP" status — the cancel trigger. Like + # ORCH-059's Confirm Deploy it is INTENTIONALLY ABSENT from _DEFAULT_STATES + # (fail-closed): environments without the status (enduro / API fallback) + # resolve `stop` to None via .get -> the cancel branch simply never activates + # (no UUID, no KeyError, no blind cancel). Create a STOP status with the + # `cancelled` group on the board to enable it (07-infra-requirements.md). + "STOP": "stop", # ORCH-066: meaningful per-stage / human-input statuses (layer B). "To Analyse": "to_analyse", "Analysis": "analysis", diff --git a/src/queue_worker.py b/src/queue_worker.py index ab3984e..dd098d3 100644 --- a/src/queue_worker.py +++ b/src/queue_worker.py @@ -187,12 +187,18 @@ class QueueWorker: # launch error so the job does not wedge as 'running' forever. logger.error(f"Worker failed to launch job {job['id']}: {e}") try: - from .db import get_job, mark_job + from .db import get_job, mark_job, get_task j = get_job(job["id"]) attempts = j.get("attempts", 0) if j else 0 max_attempts = j.get("max_attempts", 2) if j else 2 - if attempts < max_attempts: + # ORCH-090 (adr-0026 / TR-2): never requeue a job whose task is + # already terminal ({done,cancelled}) — a STOP that landed between + # claim and launch must win over the retry budget. + task = get_task(job.get("task_id")) if job.get("task_id") else None + if task and task.get("stage") in ("done", "cancelled"): + mark_job(job["id"], "cancelled", error=f"launch error (task terminal): {e}") + elif attempts < max_attempts: mark_job(job["id"], "queued", error=f"launch error: {e}") else: mark_job(job["id"], "failed", error=f"launch error: {e}") diff --git a/src/serial_gate.py b/src/serial_gate.py index ae273b7..0675e98 100644 --- a/src/serial_gate.py +++ b/src/serial_gate.py @@ -110,14 +110,19 @@ def repo_has_active_task(repo: str, exclude_task_id: int | None = None) -> bool: try: conn = db.get_db() try: + # 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. if exclude_task_id is not None: row = conn.execute( - "SELECT 1 FROM tasks WHERE repo=? AND id != ? AND stage != 'done' LIMIT 1", + "SELECT 1 FROM tasks WHERE repo=? AND id != ? " + "AND stage NOT IN ('done','cancelled') LIMIT 1", (repo, exclude_task_id), ).fetchone() else: row = conn.execute( - "SELECT 1 FROM tasks WHERE repo=? AND stage != 'done' LIMIT 1", + "SELECT 1 FROM tasks WHERE repo=? " + "AND stage NOT IN ('done','cancelled') LIMIT 1", (repo,), ).fetchone() return row is not None @@ -264,10 +269,12 @@ def build_claim_clause() -> str: repo_scope = f"AND jobs.repo IN ({repo_in}) " else: repo_scope = "" + # ORCH-090 (adr-0026): {done,cancelled} are both terminal — an EARLIER + # cancelled task no longer holds the FIFO serial gate closed. active_clause = ( "EXISTS (SELECT 1 FROM tasks t2 " "WHERE t2.repo = jobs.repo AND t2.id < jobs.task_id " - "AND t2.stage != 'done') " + "AND t2.stage NOT IN ('done','cancelled')) " ) if _freeze_layer_enabled(): freeze_clause = ( @@ -329,9 +336,10 @@ def _per_repo_snapshot(repo: str) -> dict: try: conn = db.get_db() try: + # ORCH-090 (adr-0026): terminal set {done,cancelled}. row = conn.execute( "SELECT work_item_id, stage FROM tasks " - "WHERE repo=? AND stage != 'done' ORDER BY id LIMIT 1", + "WHERE repo=? AND stage NOT IN ('done','cancelled') ORDER BY id LIMIT 1", (repo,), ).fetchone() if row: diff --git a/src/stage_engine.py b/src/stage_engine.py index c088604..fac7819 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -1656,6 +1656,28 @@ def run_deploy_finalizer(job: dict): finished_agent="deployer", ) + # ORCH-090 (ADR-001 D7 / AC-7): a STOP that arrived during the prod deploy was + # DEFERRED (cancel_requested_at). The irreversible step has now finished honestly + # above, so apply the deferred cancellation. force=True bypasses ONLY the + # critical-window guard (the INITIATED marker may still linger) — a task that + # reached terminal 'done' (SUCCESS) is an honest no-op (code is already in prod); + # a FAILED deploy rolled back to development is fully reset now. + try: + from .db import get_task as _get_task + t = _get_task(task_id) + if t and t.get("cancel_requested_at") and t.get("stage") != "cancelled": + logger.warning( + "Task %s: applying deferred STOP after deploy finalize", task_id + ) + cancel_task( + task_id, + reason="deferred STOP applied after deploy finalize", + source="deferred", + force=True, + ) + except Exception as e: # noqa: BLE001 - never break the finalizer + logger.warning("Task %s: deferred-cancel application failed: %s", task_id, e) + def run_post_deploy_monitor(job: dict): """ORCH-021 — one post-deploy monitor tick (reserved-agent, no LLM). @@ -1825,3 +1847,182 @@ def _notify_post_deploy(work_item_id: str, message: str) -> None: plane_add_comment(work_item_id, message, author="deployer") except Exception as e: # noqa: BLE001 - never break the tick logger.warning(f"post-deploy notify plane failed for {work_item_id}: {e}") + + +# --------------------------------------------------------------------------- +# ORCH-090 (ADR-001 / adr-0026): STOP-cancellation orchestration +# --------------------------------------------------------------------------- + +def cancel_task( + task_id: int, + *, + reason: str = "", + source: str = "stop", + force: bool = False, +) -> dict: + """Cancel a task: stop the active agent + full progress reset (ORCH-090). + + The single orchestration point behind the Plane STOP status (``webhooks/plane. + handle_stop``). Drives the task to the system-terminal state ``cancelled``: + + 1. **Idempotency (BR-5 / AC-6):** an absent task or one already terminal + (``stage in {done,cancelled}``) is a no-op — no re-kill, no re-cleanup, no + duplicate notification. + 2. **Critical window (ADR-001 D7 / AC-7):** if the task is mid merge/deploy + (``cancel.in_critical_window``) and not ``force``, the cancellation is + DEFERRED: stamp ``cancel_requested_at``, cancel ONLY queued jobs (never the + running deploy/merge actor), alert, and return — the deterministic deploy + finalizer applies the cancel once the irreversible step finishes honestly. + STOP NEVER touches ``main`` / force-pushes / restarts the prod container. + 3. **Full reset:** SIGTERM the running agent through the graceful cascade + (``launcher.stop_process``), cancel all jobs (terminal ``cancelled``), + clear deploy-state + release a held merge-lease (best-effort), remove the + worktree, delete the remote feature branch, then tombstone the natural keys + + flip ``stage='cancelled'`` (durable). Docs artefacts are NOT touched. + 4. **Observability (AC-10):** log + Telegram + Plane comment + tracker update. + + ``force=True`` bypasses ONLY the critical-window guard (used by the deploy + finalizer to apply a deferred cancel after the step completes) — it never + overrides the terminal-stage idempotency. Returns a small result dict for + tests/observability. never-raise: any error is logged; a notify failure never + aborts the cancellation. + """ + from .db import ( + get_task, get_active_jobs_for_task, cancel_jobs_for_task, + mark_task_cancelled, set_task_cancel_requested, + ) + from . import cancel as cancel_mod + + result = {"ok": False, "task_id": task_id, "deferred": False, + "stopped": 0, "cancelled_jobs": 0, "note": None} + + task = get_task(task_id) + if not task: + result["note"] = "no-task" + logger.info("cancel_task: no task row for task_id=%s", task_id) + return result + + stage = task.get("stage") + repo = task.get("repo") + branch = task.get("branch") or "" + work_item_id = task.get("work_item_id") or "" + + # (1) Idempotency: already terminal -> no-op. + if stage in ("done", "cancelled"): + result["ok"] = True + result["note"] = f"already-terminal:{stage}" + logger.info( + "cancel_task: task %s (%s) already terminal (stage=%s) -> no-op", + task_id, work_item_id, stage, + ) + return result + + # (2) Critical merge/deploy window -> DEFER (unless forced by the finalizer). + if not force and cancel_mod.in_critical_window(task): + set_task_cancel_requested(task_id) + result["cancelled_jobs"] = cancel_jobs_for_task(task_id, only_queued=True) + result["deferred"] = True + result["ok"] = True + result["note"] = "deferred-critical-window" + msg = ( + f"⏸️ {link_for(work_item_id)}: STOP получен во время " + f"критичного шага (merge/deploy) — отмена ОТЛОЖЕНА до честного " + f"завершения шага. main/прод не трогаются." + ) + _notify_cancel(work_item_id, task_id, msg) + logger.warning( + "cancel_task: task %s (%s) in critical window -> deferred cancel " + "(queued jobs cancelled=%s)", task_id, work_item_id, result["cancelled_jobs"], + ) + return result + + # (3) Full reset ---------------------------------------------------------- + # 3a. Stop the active agent through the graceful cascade (AC-1). Capture the + # running jobs BEFORE cancelling them so we still know their pids. + stopped = 0 + try: + from .agents.launcher import launcher + for job in get_active_jobs_for_task(task_id): + if job.get("status") == "running" and job.get("pid"): + try: + if launcher.stop_process( + job["pid"], job.get("run_id"), reason=f"STOP cancel task {task_id}" + ): + stopped += 1 + except Exception as e: # noqa: BLE001 - never-raise + logger.warning("cancel_task: stop_process failed for job %s: %s", + job.get("id"), e) + except Exception as e: # noqa: BLE001 - never-raise + logger.warning("cancel_task: agent-stop step failed for task %s: %s", task_id, e) + result["stopped"] = stopped + + # 3b. Cancel ALL jobs (terminal 'cancelled', never requeued). + result["cancelled_jobs"] = cancel_jobs_for_task(task_id) + + # 3c. Clear deploy-state sentinels + release a held merge-lease (best-effort). + # Outside a critical window the task does not hold the lease / has no + # INITIATED marker, but clearing is idempotent and harmless. + try: + self_deploy.clear_state(repo, work_item_id) + except Exception as e: # noqa: BLE001 + logger.warning("cancel_task: clear deploy-state failed for %s: %s", work_item_id, e) + try: + merge_gate.release_merge_lease(repo, branch) + except Exception as e: # noqa: BLE001 + logger.warning("cancel_task: merge-lease release failed for %s: %s", branch, e) + + # 3d. Remove the worktree + delete the remote feature branch (never main). + if branch: + try: + from .git_worktree import remove_worktree + remove_worktree(repo, branch) + except Exception as e: # noqa: BLE001 - never-raise + logger.warning("cancel_task: remove_worktree failed for %s/%s: %s", + repo, branch, e) + try: + from . import gitea + gitea.delete_remote_branch(repo, branch) + except Exception as e: # noqa: BLE001 - never-raise + logger.warning("cancel_task: delete_remote_branch failed for %s/%s: %s", + repo, branch, e) + + # 3e. Durable terminal + natural-key tombstone (docs artefacts untouched). + mark_task_cancelled(task_id) + + # (4) Observability. + note = f" ({reason})" if reason else "" + msg = ( + f"\U0001f6d1 {link_for(work_item_id)}: задача ОТМЕНЕНА (STOP){note}. " + f"Агент остановлен, job'ы сняты ({result['cancelled_jobs']}), ветка/worktree " + f"удалены, прогресс сброшен. Docs сохранены. Перезапуск — только «To Analyse»." + ) + _notify_cancel(work_item_id, task_id, msg) + result["ok"] = True + result["note"] = "cancelled" if not force else "cancelled-deferred-applied" + logger.warning( + "cancel_task: task %s (%s, repo=%s) CANCELLED (source=%s, force=%s): " + "stopped=%s, cancelled_jobs=%s", task_id, work_item_id, repo, source, force, + stopped, result["cancelled_jobs"], + ) + return result + + +def _notify_cancel(work_item_id: str, task_id: int, message: str) -> None: + """Best-effort Telegram + Plane comment + tracker update for a cancellation. + + Never raises — a notification failure must not abort the cancel (ORCH-090 FR-8). + """ + try: + send_telegram(message) + except Exception as e: # noqa: BLE001 + logger.warning("cancel notify telegram failed for %s: %s", work_item_id, e) + if work_item_id: + try: + plane_add_comment(work_item_id, message, author="deployer") + except Exception as e: # noqa: BLE001 + logger.warning("cancel notify plane failed for %s: %s", work_item_id, e) + try: + from .notifications import update_task_tracker + update_task_tracker(task_id) + except Exception as e: # noqa: BLE001 + logger.warning("cancel notify tracker failed for task %s: %s", task_id, e) diff --git a/src/stages.py b/src/stages.py index 408e3ab..0fad74b 100644 --- a/src/stages.py +++ b/src/stages.py @@ -19,6 +19,13 @@ STAGE_TRANSITIONS = { "deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"}, "deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"}, "done": {"next": None, "agent": None, "qg": None}, + # ORCH-090 (adr-0026): system-terminal sink for a STOP-cancelled task. This is + # NOT a new pipeline edge — no exit-gate of any edge changes — it only makes + # get_next_stage('cancelled') correctly return None (parallel to 'done'). The + # scheduler terminal predicate is `stage IN ('done','cancelled')`; the points + # that recognise it carry the ORCH-090 marker (serial_gate / task_deps / + # reconciler / job_reaper). + "cancelled": {"next": None, "agent": None, "qg": None}, } diff --git a/src/task_deps.py b/src/task_deps.py index 97c1353..418e032 100644 --- a/src/task_deps.py +++ b/src/task_deps.py @@ -37,9 +37,12 @@ def is_task_ready(task_id: int) -> tuple[bool, list[str]]: """Return ``(ready, waiting_on)`` for a task. ``ready`` is True when the task has no declared dependency whose predecessor - is still un-done (``tasks.stage != 'done'``). ``waiting_on`` is the list of - predecessor work-item ids (e.g. ``["ORCH-010"]``) the task is still blocked - by — used for the Telegram waiting-line / Plane visibility. + is still un-done. ORCH-090 (adr-0026): the terminal set is + ``{done, cancelled}`` — a CANCELLED predecessor is terminal and no longer + blocks the dependent (the actual SQL predicate lives in + ``db.get_unfinished_dependencies`` / ``db.claim_next_job``). ``waiting_on`` is + the list of predecessor work-item ids (e.g. ``["ORCH-010"]``) the task is still + blocked by — used for the Telegram waiting-line / Plane visibility. never-raise: any error -> ``(True, [])`` (fail OPEN — consistent with the scheduler omitting the gate when the DB read fails; a transient error must diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index 597011f..c632678 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -160,8 +160,15 @@ async def handle_issue_updated(data: dict, project_id: str = ""): # fallback) resolve to None, so the branch simply never activates (no KeyError, # no blind deploy). Checked before `approved` so the two gestures never alias. confirm_state = proj_states.get("confirm_deploy") + # ORCH-090: dedicated operator STOP status -> cancel the task (stop agent + full + # reset). fail-closed via .get (no UUID on a board without the status -> None -> + # branch never activates, exactly like confirm_deploy). Checked FIRST so a STOP + # is never aliased by to_analyse/approved/rejected. + stop_state = proj_states.get("stop") # ORCH-066: start/resume trigger is `To Analyse` (human entry-point). - if new_state == proj_states["to_analyse"]: + if stop_state and new_state == stop_state: + await handle_stop(data, project_id) + elif new_state == proj_states["to_analyse"]: await handle_status_start(data, project_id) elif confirm_state and new_state == confirm_state: await handle_confirm_deploy(data, project_id) @@ -212,6 +219,44 @@ async def handle_confirm_deploy(data: dict, project_id: str = ""): ) +async def handle_stop(data: dict, project_id: str = ""): + """ORCH-090: a human flipped the issue to the dedicated STOP status — cancel + the task (stop the active agent + full progress reset). + + Resolves the task by plane_id and delegates to the unified + ``stage_engine.cancel_task`` (run off the event loop via asyncio.to_thread — it + is synchronous and may sleep during the graceful SIGTERM cascade). Guards: + * kill-switch / repo-scope via ``cancel.applies(repo)`` (False -> no-op-log); + * idempotent — an absent / already-terminal task is a no-op inside cancel_task. + Contract is never-raise (NFR-5): any error is logged, the webhook flow never + crashes. + """ + import asyncio + from .. import cancel + from ..stage_engine import cancel_task + + plane_id = str(data.get("id") or "") + task = get_task_by_plane_id(plane_id) + if not task: + logger.info(f"STOP for {plane_id} but no task found, ignoring (no-op)") + return + + task_id = task["id"] + repo = task.get("repo", "") + if not cancel.applies(repo): + logger.info( + f"STOP for {plane_id} (task {task_id}, repo={repo}) but cancellation is " + f"not applicable (kill-switch off / out of scope); no-op" + ) + return + + logger.info(f"Task {task_id}: STOP status -> cancelling (stop agent + full reset)") + try: + await asyncio.to_thread(cancel_task, task_id, reason="Plane STOP status", source="stop") + except Exception as e: # never-raise: the webhook flow must not crash + logger.error(f"STOP handling failed for task {task_id}: {e}") + + async def handle_status_start(data: dict, project_id: str = ""): """An issue moved into In Progress. @@ -279,6 +324,36 @@ async def handle_status_start(data: dict, project_id: str = ""): ) return + # ORCH-090 (ADR-001 D6 / AC-5): close the relaunch hole. The legitimate "answer + # to Needs Input" resume is owned ONLY by the analyst (ORCH-066 — the sole + # Needs-Input setter). A manual move of an EXISTING task at any OTHER stage to + # "To Analyse" must NOT silently relaunch the mid-pipeline agent on the old + # branch (the incident pattern). Gate the relaunch to `analysis`; any other + # stage -> no-op-with-log + a best-effort Plane hint to use STOP -> To Analyse + # for a clean-slate restart. Under the kill-switch off this gate is inert + # (behaviour 1:1 as before ORCH-090). + from ..config import settings as _settings + if getattr(_settings, "stop_status_enabled", False) and current_stage != "analysis": + logger.info( + f"Status->To Analyse for {plane_id}: existing task on stage " + f"'{current_stage}' — NOT relaunching {stage_agent} (relaunch-hole closed, " + f"ORCH-090). Use STOP then To Analyse to restart from scratch." + ) + try: + _add_comment( + work_item_id, + "ℹ️ Перезапуск " + "агента сменой " + "рабочего статуса " + "отключён (ORCH-090). Для " + "перезапуска с нуля: " + "STOP → To Analyse.", + author=stage_agent, + ) + except Exception as e: + logger.error(f"Failed to post relaunch-hole comment for {work_item_id}: {e}") + return + task_desc = ( f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" f"Stage: {current_stage}\nNote: Stakeholder returned the issue to In " diff --git a/tests/test_auto_labels_invariants.py b/tests/test_auto_labels_invariants.py index 11d0c11..25eb05a 100644 --- a/tests/test_auto_labels_invariants.py +++ b/tests/test_auto_labels_invariants.py @@ -13,9 +13,10 @@ os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") def test_tc26_stage_transitions_unchanged(): from src.stages import STAGE_TRANSITIONS + # ORCH-090 (adr-0026): `cancelled` terminal sink added (parallel to `done`). assert set(STAGE_TRANSITIONS) == { "created", "analysis", "architecture", "development", "review", - "testing", "deploy-staging", "deploy", "done", + "testing", "deploy-staging", "deploy", "done", "cancelled", } # The two human gates still use their existing QG names (unchanged). assert STAGE_TRANSITIONS["analysis"]["qg"] == "check_analysis_approved" diff --git a/tests/test_config.py b/tests/test_config.py index ea4d0cf..697864b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -219,11 +219,15 @@ def test_reaper_settings_env_override(monkeypatch): # check_branch_mergeable signature is intact (AC-13). # --------------------------------------------------------------------------- def test_tc19_stage_transitions_unchanged(): - """No new pipeline stage was introduced by ORCH-065.""" + """No new pipeline EDGE was introduced by ORCH-065. + + ORCH-090 (adr-0026) adds `cancelled` as a terminal SINK (parallel to `done`), + which is not a new edge — no exit-gate of any edge changed. + """ from src.stages import STAGE_TRANSITIONS assert set(STAGE_TRANSITIONS) == { "created", "analysis", "architecture", "development", "review", - "testing", "deploy-staging", "deploy", "done", + "testing", "deploy-staging", "deploy", "done", "cancelled", } diff --git a/tests/test_plane_status_model.py b/tests/test_plane_status_model.py index 268dbf1..33edd00 100644 --- a/tests/test_plane_status_model.py +++ b/tests/test_plane_status_model.py @@ -125,6 +125,8 @@ def test_tc22_stage_transitions_unchanged(): "deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"}, "deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"}, "done": {"next": None, "agent": None, "qg": None}, + # ORCH-090 (adr-0026): terminal SINK for a STOP-cancelled task. + "cancelled": {"next": None, "agent": None, "qg": None}, } diff --git a/tests/test_qg_registry_snapshot.py b/tests/test_qg_registry_snapshot.py index 0067f7b..1c8c44a 100644 --- a/tests/test_qg_registry_snapshot.py +++ b/tests/test_qg_registry_snapshot.py @@ -56,6 +56,9 @@ _EXPECTED_TRANSITIONS = { "deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"}, "deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"}, "done": {"next": None, "agent": None, "qg": None}, + # ORCH-090 (adr-0026): terminal SINK for a STOP-cancelled task (parallel to + # `done`; not a new edge — no exit-gate changed). + "cancelled": {"next": None, "agent": None, "qg": None}, } diff --git a/tests/test_serial_gate.py b/tests/test_serial_gate.py index ada6b8b..dfa8b97 100644 --- a/tests/test_serial_gate.py +++ b/tests/test_serial_gate.py @@ -180,9 +180,11 @@ def test_snapshot_shape_and_never_raises(monkeypatch): def test_registries_unchanged(): from src.stages import STAGE_TRANSITIONS from src.qg.checks import QG_CHECKS + # ORCH-090 (adr-0026): `cancelled` is added as a terminal SINK (parallel to + # `done`), NOT a new pipeline edge — serial-gate FIFO semantics are unchanged. assert set(STAGE_TRANSITIONS) == { "created", "analysis", "architecture", "development", "review", - "testing", "deploy-staging", "deploy", "done", + "testing", "deploy-staging", "deploy", "done", "cancelled", } # No serial-gate QG check was introduced (the gate is a scheduler condition). assert not any("serial" in k for k in QG_CHECKS), "no new QG check expected" diff --git a/tests/test_stages_invariants.py b/tests/test_stages_invariants.py index a800cfb..171581a 100644 --- a/tests/test_stages_invariants.py +++ b/tests/test_stages_invariants.py @@ -39,6 +39,9 @@ _EXPECTED_TRANSITIONS = { "deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"}, "deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"}, "done": {"next": None, "agent": None, "qg": None}, + # ORCH-090 (adr-0026): terminal SINK for a STOP-cancelled task (parallel to + # `done`; not a new edge — no exit-gate changed). + "cancelled": {"next": None, "agent": None, "qg": None}, } diff --git a/tests/test_stop_status.py b/tests/test_stop_status.py new file mode 100644 index 0000000..986f8b2 --- /dev/null +++ b/tests/test_stop_status.py @@ -0,0 +1,454 @@ +"""ORCH-090 — STOP-status task cancellation + relaunch-hole close (unit + integ). + +Covers 04-test-plan.yaml TC-01..TC-14 + the ADR-001 D7 deferred-cancel path: + + TC-01 STOP recognised + routed to handle_stop; unknown task -> no-op, never-raise. + TC-02 active agent stopped via launcher.stop_process by jobs.pid; idle -> no-op. + TC-03 queued+running jobs of the task -> terminal 'cancelled'; claim skips them. + TC-04 reaper does NOT requeue a job of a terminal (cancelled) task. + TC-05 full reset: remove_worktree + delete_remote_branch called; main untouched. + TC-06 docs artefacts (and the task row) survive the reset. + TC-07 idempotency: STOP on cancelled / done / missing -> no-op, no exception. + TC-08 kill-switch off -> STOP inert; relaunch-hole gate inert. + TC-09 GET /queue carries a read-only `stop` block; never-raise. + TC-10 relaunch-hole closed: manual To Analyse on a mid-pipeline task -> no job. + TC-11 To Analyse on analysis (idle) relaunches analyst; new task -> start_pipeline. + TC-12 terminal-skip / restart-safe: reconciler skips a cancelled task; cancelled + jobs are not revived by requeue_running_jobs. + TC-13 e2e STOP: agent stopped, jobs cancelled, branch/worktree removed, durable + 'cancelled', keys tombstoned, notifications fired. + TC-14 additive DB migration is idempotent (re-init_db) + columns present. + D7 STOP in a critical merge/deploy window is DEFERRED, then applied by the + deploy finalizer. +""" +import os +import tempfile + +import pytest + +os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_stop_status.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 ( # noqa: E402 + init_db, get_db, claim_next_job, get_task, + cancel_jobs_for_task, mark_task_cancelled, get_task_by_plane_id, + requeue_running_jobs, get_job, +) +from src import config as cfg # noqa: E402 +from src import cancel as cancel_mod # noqa: E402 +from src import stage_engine # noqa: E402 + + +@pytest.fixture(autouse=True) +def fresh_db(tmp_path, monkeypatch): + dbfile = tmp_path / "stop.db" + monkeypatch.setattr(db.settings, "db_path", str(dbfile)) + # STOP feature ON, all repos. Isolate repos_dir so the critical-window probe + # (deploy markers / merge-lease) sees a clean tree by default. + monkeypatch.setattr(cfg.settings, "stop_status_enabled", True, raising=False) + monkeypatch.setattr(cfg.settings, "stop_status_repos", "", raising=False) + monkeypatch.setattr(cfg.settings, "repos_dir", str(tmp_path / "repos"), raising=False) + monkeypatch.setattr(cfg.settings, "host_repos_dir", str(tmp_path / "repos"), raising=False) + monkeypatch.setattr(cfg.settings, "serial_gate_enabled", False, raising=False) + monkeypatch.setattr(cfg.settings, "task_deps_enabled", False, raising=False) + # Silence network side effects of cancel notifications. + monkeypatch.setattr("src.stage_engine.plane_add_comment", lambda *a, **k: None, raising=False) + monkeypatch.setattr("src.notifications.update_task_tracker", lambda *a, **k: None, raising=False) + init_db() + yield + + +# --------------------------------------------------------------------------- helpers +def _make_task(plane_id, work_item_id, stage="development", repo="orchestrator", + branch=None): + branch = branch or f"feature/{work_item_id}-slug" + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, plane_issue_id, title) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (plane_id, work_item_id, repo, branch, stage, plane_id, work_item_id), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def _make_job(task_id, repo="orchestrator", agent="developer", status="running", + pid=None, run_id=None, attempts=1, max_attempts=2): + conn = get_db() + cur = conn.execute( + "INSERT INTO jobs (agent, repo, task_id, status, pid, run_id, attempts, max_attempts) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (agent, repo, task_id, status, pid, run_id, attempts, max_attempts), + ) + jid = cur.lastrowid + conn.commit() + conn.close() + return jid + + +def _job_status(job_id): + j = get_job(job_id) + return j["status"] if j else None + + +def _stub_full_reset(monkeypatch): + """Stub the side-effecting cleanup steps (signals / git / gitea) of a full reset.""" + calls = {"stop": [], "worktree": [], "branch": []} + from src.agents.launcher import launcher + + def _stop(pid, run_id, *, reason="stop"): + calls["stop"].append((pid, run_id, reason)) + return True + monkeypatch.setattr(launcher, "stop_process", _stop, raising=True) + monkeypatch.setattr("src.git_worktree.remove_worktree", + lambda repo, branch: calls["worktree"].append((repo, branch)), + raising=True) + monkeypatch.setattr("src.gitea.delete_remote_branch", + lambda repo, branch: calls["branch"].append((repo, branch)) or True, + raising=True) + return calls + + +# =========================================================================== TC-01 +@pytest.mark.asyncio +async def test_tc01_stop_routed_and_unknown_is_noop(monkeypatch): + from src.webhooks import plane as plane_wh + + proj_states = { + "stop": "STOP-UUID", "to_analyse": "TA-UUID", "approved": "AP-UUID", + "rejected": "RJ-UUID", "confirm_deploy": None, + } + monkeypatch.setattr("src.plane_sync.get_project_states", lambda pid: proj_states) + seen = [] + + async def _stub_stop(data, project_id=""): + seen.append(data.get("id")) + monkeypatch.setattr(plane_wh, "handle_stop", _stub_stop) + + # STOP state -> routed to handle_stop. + await plane_wh.handle_issue_updated({"id": "PL-1", "state": {"id": "STOP-UUID"}}, "proj") + assert seen == ["PL-1"] + + # A non-STOP state does not route to handle_stop. + await plane_wh.handle_issue_updated({"id": "PL-2", "state": {"id": "AP-UUID"}}, "proj") + assert seen == ["PL-1"] + + # Unknown task on the real handler -> no-op, never raises. + await plane_wh.handle_stop({"id": "does-not-exist"}, "proj") + + +# =========================================================================== TC-02 +def test_tc02_stop_active_agent_by_pid(monkeypatch): + calls = _stub_full_reset(monkeypatch) + tid = _make_task("PL-10", "ORCH-310", stage="development") + _make_job(tid, status="running", pid=4242, run_id=77) + + res = stage_engine.cancel_task(tid) + assert res["ok"] and not res["deferred"] + assert calls["stop"] == [(4242, 77, f"STOP cancel task {tid}")] + assert res["stopped"] == 1 + + +def test_tc02_idle_agent_no_stop(monkeypatch): + calls = _stub_full_reset(monkeypatch) + tid = _make_task("PL-11", "ORCH-311", stage="development") + _make_job(tid, status="queued", pid=None) # no running process + + res = stage_engine.cancel_task(tid) + assert res["ok"] and res["stopped"] == 0 + assert calls["stop"] == [] + + +# =========================================================================== TC-03 +def test_tc03_jobs_cancelled_and_claim_skips(monkeypatch): + _stub_full_reset(monkeypatch) + tid = _make_task("PL-20", "ORCH-320", stage="development") + jq = _make_job(tid, status="queued") + jr = _make_job(tid, status="running", pid=None) + + stage_engine.cancel_task(tid) + assert _job_status(jq) == "cancelled" + assert _job_status(jr) == "cancelled" + # claim_next_job selects only status='queued' -> a cancelled job is never claimed. + assert claim_next_job() is None + + +def test_tc03_cancel_jobs_helper_only_queued(monkeypatch): + tid = _make_task("PL-21", "ORCH-321") + jq = _make_job(tid, status="queued") + jr = _make_job(tid, status="running", pid=None) + n = cancel_jobs_for_task(tid, only_queued=True) + assert n == 1 + assert _job_status(jq) == "cancelled" + assert _job_status(jr) == "running" # the running deploy/merge actor is left alone + + +# =========================================================================== TC-04 +def test_tc04_reaper_does_not_requeue_terminal_task(monkeypatch): + from src.job_reaper import JobReaper + tid = _make_task("PL-30", "ORCH-330", stage="development") + jid = _make_job(tid, status="running", pid=999999, attempts=1, max_attempts=2) + # Task is flipped to cancelled (as STOP would) while the job is still running. + mark_task_cancelled(tid) + + reaper = JobReaper() + job = get_job(jid) + reaper._reap_unknown_outcome(job, reason="dead pid") + # NOT requeued (attempts terminal 'cancelled'. + assert _job_status(jid) == "cancelled" + + +# =========================================================================== TC-05 +def test_tc05_full_reset_removes_branch_and_worktree(monkeypatch): + calls = _stub_full_reset(monkeypatch) + tid = _make_task("PL-40", "ORCH-340", stage="review", branch="feature/ORCH-340-x") + + stage_engine.cancel_task(tid) + assert calls["worktree"] == [("orchestrator", "feature/ORCH-340-x")] + assert calls["branch"] == [("orchestrator", "feature/ORCH-340-x")] + + +def test_tc05_delete_remote_branch_refuses_main(): + from src import gitea + # main is never deletable by the cancel path (self-hosting safety, NFR-3). + assert gitea.delete_remote_branch("orchestrator", "main") is False + assert gitea.delete_remote_branch("orchestrator", "master") is False + + +# =========================================================================== TC-06 +def test_tc06_docs_and_task_row_survive(monkeypatch, tmp_path): + _stub_full_reset(monkeypatch) + tid = _make_task("PL-50", "ORCH-350", stage="development") + # A stand-in docs artefact: cancel must not delete it. + docs = tmp_path / "docs" / "work-items" / "ORCH-350" + docs.mkdir(parents=True) + (docs / "02-trz.md").write_text("trz") + + stage_engine.cancel_task(tid) + assert (docs / "02-trz.md").exists(), "docs artefacts must be preserved" + # The task ROW is kept (durable audit), flipped to cancelled. + assert get_task(tid)["stage"] == "cancelled" + + +# =========================================================================== TC-07 +def test_tc07_idempotent_on_cancelled_done_missing(monkeypatch): + calls = _stub_full_reset(monkeypatch) + # already cancelled + tid = _make_task("PL-60", "ORCH-360", stage="cancelled") + res = stage_engine.cancel_task(tid) + assert res["ok"] and res["note"].startswith("already-terminal") + assert calls["stop"] == [] and calls["branch"] == [] + # done + tid2 = _make_task("PL-61", "ORCH-361", stage="done") + res2 = stage_engine.cancel_task(tid2) + assert res2["note"].startswith("already-terminal") + # missing + res3 = stage_engine.cancel_task(999999) + assert res3["note"] == "no-task" + + +# =========================================================================== TC-08 +def test_tc08_kill_switch_off_inert(monkeypatch): + monkeypatch.setattr(cfg.settings, "stop_status_enabled", False, raising=False) + assert cancel_mod.applies("orchestrator") is False + + +@pytest.mark.asyncio +async def test_tc08_kill_switch_off_handle_stop_noop(monkeypatch): + monkeypatch.setattr(cfg.settings, "stop_status_enabled", False, raising=False) + calls = _stub_full_reset(monkeypatch) + from src.webhooks import plane as plane_wh + tid = _make_task("PL-70", "ORCH-370", stage="development") + _make_job(tid, status="running", pid=4242) + await plane_wh.handle_stop({"id": "PL-70"}, "proj") + # Nothing was cancelled (kill-switch off -> applies() False -> no-op). + assert calls["stop"] == [] + assert get_task(tid)["stage"] == "development" + + +def test_tc08_scope_csv(monkeypatch): + monkeypatch.setattr(cfg.settings, "stop_status_repos", "enduro-trails", raising=False) + assert cancel_mod.applies("enduro-trails") is True + assert cancel_mod.applies("orchestrator") is False + + +# =========================================================================== TC-09 +def test_tc09_queue_has_stop_block_and_keeps_keys(monkeypatch): + import asyncio + from src import main + payload = asyncio.run(main.queue()) + for key in ("counts", "serial_gate", "task_deps", "auto_labels", "recent"): + assert key in payload, f"existing /queue key '{key}' preserved" + assert "stop" in payload + blk = payload["stop"] + assert blk["enabled"] is True + assert "repos" in blk and "cancelled_count" in blk and "recent" in blk + + +def test_tc09_snapshot_never_raises(monkeypatch): + # Force a DB error inside the snapshot -> minimal dict, no raise. + monkeypatch.setattr("src.db.cancelled_tasks_snapshot", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom"))) + snap = cancel_mod.snapshot() + assert snap["enabled"] is True and snap["cancelled_count"] == 0 + + +# =========================================================================== TC-10 +@pytest.mark.asyncio +async def test_tc10_relaunch_hole_closed_midpipeline(monkeypatch): + from src.webhooks import plane as plane_wh + monkeypatch.setattr("src.plane_sync.add_comment", lambda *a, **k: None, raising=False) + monkeypatch.setattr("src.plane_sync.set_issue_analysis", lambda *a, **k: None, raising=False) + tid = _make_task("PL-80", "ORCH-380", stage="development") + + await plane_wh.handle_status_start({"id": "PL-80"}, "proj") + # No stage agent was relaunched (no job created) for a mid-pipeline task. + conn = get_db() + n = conn.execute("SELECT COUNT(*) FROM jobs WHERE task_id=?", (tid,)).fetchone()[0] + conn.close() + assert n == 0 + + +# =========================================================================== TC-11 +@pytest.mark.asyncio +async def test_tc11_analysis_idle_relaunches_analyst(monkeypatch): + from src.webhooks import plane as plane_wh + monkeypatch.setattr("src.plane_sync.add_comment", lambda *a, **k: None, raising=False) + monkeypatch.setattr("src.plane_sync.set_issue_analysis", lambda *a, **k: None, raising=False) + tid = _make_task("PL-90", "ORCH-390", stage="analysis") + + await plane_wh.handle_status_start({"id": "PL-90"}, "proj") + conn = get_db() + rows = conn.execute("SELECT agent FROM jobs WHERE task_id=?", (tid,)).fetchall() + conn.close() + assert [r[0] for r in rows] == ["analyst"], "analyst resume is still legitimate" + + +@pytest.mark.asyncio +async def test_tc11_new_task_starts_pipeline(monkeypatch): + from src.webhooks import plane as plane_wh + started = [] + + async def _stub_start(data, project_id=""): + started.append(data.get("id")) + monkeypatch.setattr(plane_wh, "start_pipeline", _stub_start) + await plane_wh.handle_status_start({"id": "PL-NEW"}, "proj") + assert started == ["PL-NEW"] # the ONLY pipeline-start entry point + + +# =========================================================================== TC-12 +def test_tc12_reconciler_skips_cancelled(monkeypatch): + from src.reconciler import Reconciler + # Avoid any Plane network in the gate pass. + monkeypatch.setattr("src.reconciler.fetch_issue_state", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("no net")), + raising=False) + tid = _make_task("PL-100", "ORCH-400", stage="development") + mark_task_cancelled(tid) + rec = Reconciler() + rec.reconcile_gate_once() + assert rec.skipped_terminal_total == 1 + + +def test_tc12_requeue_running_does_not_revive_cancelled(monkeypatch): + tid = _make_task("PL-101", "ORCH-401", stage="development") + jc = _make_job(tid, status="running", pid=None) + cancel_jobs_for_task(tid) # -> cancelled + assert _job_status(jc) == "cancelled" + # Startup recovery flips only 'running' jobs; a cancelled job is untouched. + requeue_running_jobs() + assert _job_status(jc) == "cancelled" + + +# =========================================================================== TC-13 +def test_tc13_end_to_end_stop(monkeypatch): + calls = _stub_full_reset(monkeypatch) + tid = _make_task("PL-110", "ORCH-410", stage="review", branch="feature/ORCH-410-e2e") + jr = _make_job(tid, status="running", pid=5555, run_id=11) + jq = _make_job(tid, status="queued") + + res = stage_engine.cancel_task(tid, reason="Plane STOP status") + assert res["ok"] and not res["deferred"] + # agent stopped + assert calls["stop"] and calls["stop"][0][0] == 5555 + # jobs cancelled + assert _job_status(jr) == "cancelled" and _job_status(jq) == "cancelled" + # worktree + branch removed + assert calls["worktree"] and calls["branch"] + # durable terminal + key tombstone (re-create via To Analyse no longer collides) + t = get_task(tid) + assert t["stage"] == "cancelled" and t["cancelled_at"] + assert t["plane_id"].endswith(f"#cancelled-{tid}") + assert t["work_item_id"].endswith(f"#cancelled-{tid}") + # plane_issue_id is tombstoned too (the lookup ORs on it) but the original UUID + # remains recoverable from the parseable suffix (audit link preserved). + assert t["plane_issue_id"] == f"PL-110#cancelled-{tid}" + assert t["plane_issue_id"].split("#cancelled-")[0] == "PL-110" + assert get_task_by_plane_id("PL-110") is None # freed for a fresh start + + +# =========================================================================== TC-14 +def test_tc14_migration_idempotent_and_columns_present(): + # Re-running init_db must not fail (idempotent _ensure_column). + init_db() + init_db() + conn = get_db() + cols = {r[1] for r in conn.execute("PRAGMA table_info(tasks)").fetchall()} + conn.close() + assert "cancelled_at" in cols and "cancel_requested_at" in cols + + +def test_tc14_existing_contracts_intact(): + # The additive job status set still has the original statuses working. + tid = _make_task("PL-120", "ORCH-420") + jid = _make_job(tid, status="queued") + # A queued job is still claimable when no gate blocks it. + claimed = claim_next_job() + assert claimed is not None and claimed["id"] == jid + + +# =========================================================================== D7 +def test_d7_stop_in_critical_window_defers(monkeypatch): + calls = _stub_full_reset(monkeypatch) + from src import self_deploy + tid = _make_task("PL-130", "ORCH-430", stage="deploy", branch="feature/ORCH-430-d") + # self-deploy Phase B initiated -> critical window. + self_deploy.write_marker("orchestrator", "ORCH-430", self_deploy.INITIATED, content="1") + jq = _make_job(tid, status="queued") + jr = _make_job(tid, status="running", pid=7777) # the deploy actor + + res = stage_engine.cancel_task(tid) + assert res["deferred"] is True and res["ok"] + # Only queued jobs cancelled; the running deploy actor is NOT killed. + assert _job_status(jq) == "cancelled" + assert _job_status(jr) == "running" + assert calls["stop"] == [] and calls["branch"] == [] + # The deferred flag is durable; the task is NOT yet terminal. + t = get_task(tid) + assert t["cancel_requested_at"] and t["stage"] == "deploy" + + +def test_d7_in_critical_window_detection(monkeypatch): + from src import self_deploy + task = {"repo": "orchestrator", "work_item_id": "ORCH-431", "branch": "feature/x"} + assert cancel_mod.in_critical_window(task) is False + self_deploy.write_marker("orchestrator", "ORCH-431", self_deploy.INITIATED, content="1") + assert cancel_mod.in_critical_window(task) is True + + +def test_d7_deferred_applied_by_finalizer(monkeypatch): + """After the irreversible step finishes, the finalizer applies the deferred cancel.""" + calls = _stub_full_reset(monkeypatch) + tid = _make_task("PL-140", "ORCH-440", stage="development", branch="feature/ORCH-440-d") + # Mark a deferred cancellation pending (as the critical-window path would). + db.set_task_cancel_requested(tid) + + # force=True is what run_deploy_finalizer uses once the step completed honestly. + res = stage_engine.cancel_task(tid, force=True, source="deferred") + assert res["ok"] and not res["deferred"] + assert get_task(tid)["stage"] == "cancelled" + assert calls["branch"], "deferred cancel applies the full reset" -- 2.49.1 From 46c59bad992039e6168ce7cacc9941ae298c84c3 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 21:10:24 +0300 Subject: [PATCH 5/9] reviewer(ET): auto-commit from reviewer run_id=499 --- docs/work-items/ORCH-090/12-review.md | 135 ++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/work-items/ORCH-090/12-review.md diff --git a/docs/work-items/ORCH-090/12-review.md b/docs/work-items/ORCH-090/12-review.md new file mode 100644 index 0000000..9e35400 --- /dev/null +++ b/docs/work-items/ORCH-090/12-review.md @@ -0,0 +1,135 @@ +--- +verdict: REQUEST_CHANGES +work_item: ORCH-090 +stage: review +author_agent: reviewer +status: changes-requested +created_at: 2026-06-09 +model_used: claude-opus-4-8 +type: review +work_item_id: ORCH-090 +version: 1 +--- + +# Review ORCH-090 — Механизм отмены задачи: статус STOP + +## Summary + +Реализация STOP-отмены сделана аккуратно, по канону прошлых задач (leaf `src/cancel.py` +never-raise, kill-switch `stop_status_enabled`, аддитивные миграции, fail-closed маршрутизация по +образцу `confirm_deploy`/ORCH-059). Кросс-каттинг `{done}` → `{done, cancelled}` проведён +исчерпывающе и консистентно (serial_gate / task_deps / stages / db / job_reaper / queue_worker), в +точности по adr-0026. Документация — golden-source качества (README, architecture/README, +internals, CHANGELOG, CLAUDE.md, .env.example, оба ADR, все номерные доки). Полный регресс +`pytest tests/` зелёный (1345 passed); новый `tests/test_stop_status.py` покрывает AC-1…AC-10 + D7. + +**Однако** обнаружен **P1** дефект в логике безопасного прерывания (AC-7 / D7): «критическое окно» +определено слишком широко и **отменённая задача self-hosting-репо застревает (wedge)** в самом +вероятном сценарии STOP — отмене задачи, ожидающей `Confirm Deploy`. Это блокирует приёмку. + +Оси проверки: ✅ ТЗ (кроме AC-7 — см. P1) · ⚠️ ADR (D7 реализован у́же, чем намеренная семантика) · +✅ качество кода · ✅ документация. + +## Findings + +### P0 — Blocker +- (нет) + +### P1 — Must fix + +- [ ] **Deferred-cancel НИКОГДА не применяется при STOP на стадии `deploy` в ожидании «Confirm + Deploy» → отменённая задача self-hosting-репо застревает и заклинивает очередь репо.** (AC-7 / + ADR-001 D7 / adr-0026; NFR-3 self-hosting). + - **Где:** `src/cancel.py::in_critical_window` (merge-lease ветка, стр. 107–114) + + `src/stage_engine.py::cancel_task` deferred-ветка (стр. ~1920–1937) + единственная точка + применения deferred-cancel в `run_deploy_finalizer` (стр. 1659–1679). + - **Сценарий (сверено по коду):** для self-hosting-репо merge-gate на ребре `deploy-staging → + deploy` держит merge-lease на PASS и **не отпускает** его до `deploy→done` / rollback (см. + `src/qg/checks.py::check_branch_mergeable`, return True на стр. 701 и 711 — без + `release_merge_lease`; подтверждено docstring'ами `_handle_merge_gate` стр. 906–907 и + `_handle_merge_verify` стр. 1132–1133). Phase A (`_handle_self_deploy_phase_a`) переводит + задачу на стадию `deploy` и ждёт ручного «Confirm Deploy», **продолжая держать lease**; + sentinel `INITIATED` ещё НЕ записан (он пишется в Phase B, после Confirm Deploy). + - Поэтому при STOP в этом состоянии `in_critical_window` возвращает `True` **только** по признаку + «держим merge-lease» → `cancel_task` уходит в **deferred**-ветку: ставит `cancel_requested_at`, + снимает лишь `queued`-job'ы (их нет), шлёт алерт и **возвращается, не отпустив lease** (выход + до шага 3c). Running-актора деплоя в Phase A нет, `run_deploy_finalizer` запускается **только** + после Phase B (Confirm Deploy), которого оператор намеренно не делает (он нажал STOP). Итог: + единственная точка применения deferred-cancel недостижима → **отмена не применяется никогда**. + - **Последствия:** (1) интент оператора молча теряется — задача НЕ отменяется (ровно тот класс + «ручной хирургии», который фича призвана устранить, BRD §1 / инцидент ORCH-087); (2) задача + остаётся на `deploy` (нетерминальна) и **держит merge-lease** → serial-gate (ORCH-088) считает + репо «активным» и блокирует вход новых задач в `analysis`, а merge-lease блокирует мержи всего + репо — до stale-reclaim ORCH-065 (частично) / ручного вмешательства. Это **самый частый + реальный сценарий STOP для self-hosting** («не катить в прод — отменить»), и фича в нём + ломается. + - **Причина:** «критическое окно» (необратимый шаг) переопределено как «держим merge-lease», но + Phase-A-ожидание Confirm Deploy **полностью обратимо** (ничего не смержено и не задеплоено). + По смыслу D7 необратимость = реально начатый необратимый шаг (sentinel `INITIATED` Phase B / + идущий merge), а не сам факт удержания lease в ожидании человека. + - **Как чинить (на выбор developer/architect, не предписываю реализацию):** сузить + `in_critical_window` так, чтобы удержание merge-lease на стадии `deploy` БЕЗ маркера `INITIATED` + и без идущего merge не считалось критическим окном (т.е. STOP в ожидании Confirm Deploy = + немедленный полный сброс, который сам отпустит lease в шаге 3c); ЛИБО добавить применение + deferred-cancel в точку, гарантированно достижимую и для merge-lease-окна (как и заявляет + ADR-001 D7: «`run_deploy_finalizer` Phase C / **`_handle_merge_verify`**» — второй финализатор в + коде не задействован). Любой вариант обязан: не оставлять задачу нетерминальной с удержанным + lease; покрыть тест-кейсом «STOP на `deploy` в ожидании Confirm Deploy → задача `cancelled`, + lease отпущен, репо разблокирован». + - **Тест-разрыв:** D7-кейсы (`test_d7_*`) покрывают только окно с `INITIATED`-маркером; окно + «держим lease, ждём Confirm Deploy» не покрыто — отсюда дефект и прошёл. + +### P2 — Should fix + +- [ ] **Deferred-ветка не идемпотентна по уведомлениям (AC-6 «нет Telegram-спама дублями»).** + `src/stage_engine.py::cancel_task` — терминальная идемпотентность (`stage in {done,cancelled}`) + защищает full-reset путь, но в критическом окне повторный STOP каждый раз заново вызывает + `set_task_cancel_requested` (идемпотентно) **и** `_notify_cancel` (Telegram + Plane-коммент) — + повторные STOP во время merge/deploy спамят дублирующими уведомлениями. Предложение: слать алерт + «отложено» лишь при первом переходе `cancel_requested_at IS NULL → NOW` (его уже умеет различать + `set_task_cancel_requested`, возвращая факт первой простановки). + +### P3 — Nice to have + +- [ ] **«Завис» `cancel_requested_at` на успешно задеплоенной задаче → вечный `pending` в + `GET /queue`.** При SUCCESS-деплое `run_deploy_finalizer` вызывает `cancel_task(force=True)`, + который видит `stage='done'` → «already-terminal» no-op и **не очищает** `cancel_requested_at`. + `db.cancelled_tasks_snapshot` считает `pending = cancel_requested_at IS NOT NULL AND stage != + 'cancelled'` → done-задача с бывшим deferred-STOP навсегда показывается «pending». Чисто + наблюдаемость; предложение — очищать `cancel_requested_at` при честном no-op после завершения. +- [ ] **adr-0026 п.6 (post-deploy monitor «не тикает по отменённой задаче») в коде не реализован** + (`run_post_deploy_monitor` не сверяет терминал задачи). Фактически безвреден и недостижим: + post-deploy наблюдение идёт только ПОСЛЕ `done`, а STOP на `done` — no-op, поэтому отменённая + задача в монитор не попадает. Рекомендация: либо снять пункт из adr-0026 как нерелевантный, либо + добавить дешёвый терминал-гард для строгого соответствия ADR. +- [ ] Косметика: «рваная» строковая склейка комментария relaunch-hole в + `src/webhooks/plane.py` (стр. 345–350) — собрать в одну строку для читаемости. + +## Документация + +**Обновлена полностью и качественно — отдельных findings нет.** Проверено пофайльно: +- `README.md` — таблица env (`ORCH_STOP_STATUS_ENABLED`/`ORCH_STOP_STATUS_REPOS`), новый раздел + «Отмена задачи: статус STOP», обновлён список job-статусов (`cancelled`), инфра-предусловие. +- `docs/architecture/README.md` — новый раздел «STOP / отмена задачи (реализовано)», обновлены + раздел «База данных» (колонки/тумбстон/статусы) и таблица API (`/queue` несёт блок `stop`). +- `docs/architecture/internals.md` — `STAGE_TRANSITIONS` (сток `cancelled`), терминал-предикат + `{done,cancelled}`, job-статусы. +- `CHANGELOG.md` (`feat:`), `CLAUDE.md` (раздел «Отмена задачи: статус STOP (ORCH-090)»), + `.env.example` — присутствуют и согласованы. +- ADR: локальный `06-adr/ADR-001-stop-cancel-task.md` + сквозной + `docs/architecture/adr/adr-0026-stop-cancel-task.md`; уточнение D4 (тумбстон `plane_issue_id`) + честно отражено и в коде, и в доках. Все номерные доки `01..10` на месте. +- Раздела README «Известные ограничения», который ORCH-090 закрывал бы (ORCH-079), нет — + обзорная витрина не рассинхронена. + +Трассировка маркеров (TRACEABILITY.md): правки маркированных инвариантов `serial_gate`/ORCH-088 и +`task_deps`/ORCH-026 сверены с их ADR — расширение терминал-набора до `{done,cancelled}` сохраняет +FIFO-семантику (`t2.id < jobs.task_id`) и dep-готовность (терминальный предшественник), инварианты +не сломаны. `STAGE_TRANSITIONS` exit-гейты / `QG_CHECKS` / `check_*` — не тронуты (подтверждено +анти-регресс-снапшотами, зелёные). + +## Вердикт + +`REQUEST_CHANGES` — из-за P1 (deferred-cancel недостижим при STOP в ожидании Confirm Deploy → +wedge self-hosting-репо). Остальное (P2/P3) — на усмотрение, но P1 обязателен к исправлению с +покрывающим тест-кейсом перед повторным review. -- 2.49.1 From aae65969d51c75ffd5f2711474af3e7a00490890 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 21:19:28 +0300 Subject: [PATCH 6/9] fix(cancel): narrow STOP critical-window so deploy-park cancel applies (ORCH-090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review P1: a STOP while a self-hosting task is PARKED on `deploy` awaiting the manual `Confirm Deploy` was classified as a critical merge/deploy window solely because the task still held the per-repo merge-lease (held from merge-gate through deploy->done). That window is fully reversible — nothing is merged or deployed yet (the irreversible merge_pr runs later in _handle_merge_verify, always under an INITIATED marker). So the cancel was DEFERRED to run_deploy_finalizer, which only runs after Phase B (Confirm Deploy) — the very step the operator pressed STOP to avoid. Result: the deferred cancel was never applied, the task wedged non-terminal holding the lease, blocking the repo's serial-gate (ORCH-088) and merges. Fix: gate the merge-lease branch of cancel.in_critical_window on an actively RUNNING actor (_task_has_running_actor). Lease held + running deploy/merge job -> still deferred (genuine in-flight step). Lease held + no running actor (idle deploy parking) -> NOT critical -> immediate full reset, which itself releases the lease (step 3c) and drives the task terminal. INITIATED-marker deferral unchanged. Also fixes review P2 (AC-6): set_task_cancel_requested now returns the first-stamp fact (rowcount), and the deferred branch only notifies on the first transition — a repeated STOP while still deferred no longer spams duplicate notifications. Tests: test_d7_lease_held_idle_parking_is_not_critical, test_d7_lease_held_with_running_actor_still_critical, test_d7_stop_on_deploy_awaiting_confirm_full_resets, test_d7_repeated_stop_in_critical_window_no_duplicate_notify. Full suite green (1349). Refs: ORCH-090 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 +-- CLAUDE.md | 17 +++++++--- docs/architecture/README.md | 12 +++++-- src/cancel.py | 59 ++++++++++++++++++++++++++++----- src/db.py | 13 +++++--- src/stage_engine.py | 22 ++++++++----- tests/test_stop_status.py | 66 +++++++++++++++++++++++++++++++++++++ 7 files changed, 162 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c4cad0..76db55f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,12 @@ - **Распознавание (fail-closed):** новый логический ключ `stop` в `_PLANE_NAME_TO_KEY` (`"STOP" → "stop"`), **намеренно отсутствует** в `_DEFAULT_STATES` (по образцу `confirm_deploy`/ORCH-059) → доска без статуса STOP резолвит `None` → ветка не активируется (нет `KeyError`, нет слепой отмены). `handle_issue_updated` маршрутизирует `stop` → `handle_stop` → `stage_engine.cancel_task` (проверяется ПЕРВЫМ, до to_analyse/approved/rejected). - **Полный сброс (вне критичного окна, AC-1..AC-4):** graceful SIGTERM активного агента через переиспользуемый каскад `launcher.stop_process` (вынесен из `_watchdog`: SIGTERM → grace → SIGKILL) по `jobs.pid`; `db.cancel_jobs_for_task` (queued/running → терминальный `cancelled`, нигде не реквью'ится — `claim_next_job` берёт только `queued`); `git_worktree.remove_worktree` + новый never-raise `src/gitea.py::delete_remote_branch` (удаляет **только** feature-ветку; `main`/`master` — явный гард-отказ; без force-push); durable `stage='cancelled'` + `cancelled_at`; **тумбстон** натуральных ключей суффиксом `#cancelled-`. Docs-артефакты (`01..17`) сохраняются. - **Уточнение ADR-001 D4 (при реализации):** ADR предлагал сохранить `plane_issue_id` нетронутым, но `get_task_by_plane_id`/`create_task_atomic` матчат по `plane_id OR plane_issue_id` — нетумбстоненный `plane_issue_id` заблокировал бы clean-slate re-create (BR-3/TR-4). Поэтому `plane_issue_id` тоже тумбстонится; исходный UUID (== исходный `plane_id` во всех путях создания) парсится из детерминированного суффикса для аудита. Зафиксировано в коде/`docs/architecture/README.md`/CLAUDE.md. - - **Безопасное прерывание merge/deploy (AC-7, NFR-3):** STOP в критическом окне (self-deploy `INITIATED`-sentinel ORCH-036 / держание merge-lease ORCH-043) → **отложенная отмена** (`cancel.in_critical_window` fail-CLOSED): durable `tasks.cancel_requested_at`, снимаются только `queued`-job'ы (running-актор деплоя/мержа не трогается), алерт; детерминированный `run_deploy_finalizer` доводит необратимый шаг до честного исхода и применяет отмену (`cancel_task(force=True)`; задача, дошедшая до `done`, — честный no-op, код уже в проде). STOP **никогда** не трогает `main`/force-push/прод-контейнер/detached-процесс. + - **Безопасное прерывание merge/deploy (AC-7, NFR-3):** STOP в критическом окне → **отложенная отмена** (`cancel.in_critical_window` fail-CLOSED): durable `tasks.cancel_requested_at`, снимаются только `queued`-job'ы (running-актор деплоя/мержа не трогается), алерт; детерминированный `run_deploy_finalizer` доводит необратимый шаг до честного исхода и применяет отмену (`cancel_task(force=True)`; задача, дошедшая до `done`, — честный no-op, код уже в проде). «Критическое окно» = реально начатый необратимый шаг: self-deploy `INITIATED`-sentinel (ORCH-036; детач-деплой + поздний `merge_pr` в `_handle_merge_verify` идут под тем же маркером) **либо** держание merge-lease (ORCH-043) **И** активно бегущий актор (running-job). STOP **никогда** не трогает `main`/force-push/прод-контейнер/detached-процесс. + - **Фикс P1 (ORCH-090 review, attempt 2): deferred-cancel недостижим при STOP в ожидании `Confirm Deploy` → wedge.** Для self-hosting merge-lease держится от merge-gate (ребро `deploy-staging → deploy`) до `deploy → done`, включая всё время, пока задача **припаркована** на `deploy` в ожидании ручного `Confirm Deploy` (Phase A) — но это окно **полностью обратимо** (ничего не смержено/задеплоено; необратимый `merge_pr` идёт позже в `_handle_merge_verify` уже под `INITIATED`). Прежде голое держание lease классифицировалось как «критичное» → STOP уходил в deferred-ветку, отмену применял бы только `run_deploy_finalizer` (после Phase B), которого оператор, нажавший STOP именно чтобы НЕ деплоить, никогда не запустит → отмена **не применялась никогда**, задача застревала нетерминальной с удержанным lease, клиня serial-gate репо (ORCH-088) и мержи. Фикс: merge-lease-ветка `in_critical_window` сужена — критично, лишь когда lease держится **И** есть бегущий актор (`_task_has_running_actor`, running-job); припаркованное окно без актора → НЕ критично → немедленный полный сброс (сам отпускает lease в шаге 3c). Новые тесты `test_d7_lease_held_idle_parking_is_not_critical` / `test_d7_lease_held_with_running_actor_still_critical` / `test_d7_stop_on_deploy_awaiting_confirm_full_resets`. - **Кросс-каттинг (adr-0026):** предикат «задача терминальна» расширен `{done}` → `{done, cancelled}` в `serial_gate.py` (ORCH-088: `repo_has_active_task`, claim-фрагмент, snapshot), `db.claim_next_job`/`get_unfinished_dependencies` (task_deps ORCH-026) и `stages.py`-сток — иначе отменённая задача заклинила бы очередь репо (TR-1); reconciler-терминал-скип уже знал `cancelled` (ORCH-086 D2). `job_reaper`/`queue_worker` ПЕРЕД авто-requeue сверяют терминал задачи → помечают job `cancelled`, не реквью'ят (закрыта гонка SIGTERM/reaper, TR-2). - **Закрытие дыры релонча (AC-5, D6):** `handle_status_start` больше не релончит агента середины пайплайна при ручном переводе в промежуточный статус — relaunch ограничен стадией `analysis` (единственный владелец Needs Input, ORCH-066); единственный вход к запуску пайплайна остаётся «To Analyse» (`start_pipeline`). Под `stop_status_enabled=false` гейт инертен (1:1 как раньше). - **Флаги/наблюдаемость:** `stop_status_enabled` (kill-switch, env `ORCH_STOP_STATUS_ENABLED`) + `stop_status_repos` (CSV, пусто → все репо); leaf `src/cancel.py` (`applies`/`in_critical_window`/`snapshot`, never-raise); read-only блок `stop` в `GET /queue`; лог + Telegram (кликабельный номер) + Plane-коммент + `update_task_tracker`. Аддитивные идемпотентные миграции (`_ensure_column` для `cancelled_at`/`cancel_requested_at`). **Инфра-предусловие:** создать статус **STOP** с группой `cancelled` на доске Plane проекта ORCH (его отсутствие = fail-safe no-op). - - Тесты: `tests/test_stop_status.py` (TC-01..TC-14 + D7-кейсы, 26 кейсов; SIGTERM/git/gitea замоканы — ни один тест не шлёт сигнал/не трогает сеть); обновлены анти-регресс-тесты STAGE_TRANSITIONS 5 прошлых задач (добавлен терминал-сток `cancelled`); полный регресс `tests/` зелёный (1345). Документация: `docs/architecture/README.md` (статус «реализовано» + блок `/queue` + раздел БД), `CLAUDE.md`, `README.md`, `.env.example`. ADR: `docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md`, сквозной `docs/architecture/adr/adr-0026-stop-cancel-task.md`. Откат: `ORCH_STOP_STATUS_ENABLED=false` (аддитивные колонки/терминал-набор инертны при отсутствии отменённых задач). + - Тесты: `tests/test_stop_status.py` (TC-01..TC-14 + D7-кейсы, включая 3 новых P1-кейса для окна «припаркован на `deploy`, ждёт Confirm Deploy»; SIGTERM/git/gitea замоканы — ни один тест не шлёт сигнал/не трогает сеть); обновлены анти-регресс-тесты STAGE_TRANSITIONS 5 прошлых задач (добавлен терминал-сток `cancelled`); полный регресс `tests/` зелёный (1348). Документация: `docs/architecture/README.md` (статус «реализовано» + блок `/queue` + раздел БД), `CLAUDE.md`, `README.md`, `.env.example`. ADR: `docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md`, сквозной `docs/architecture/adr/adr-0026-stop-cancel-task.md`. Откат: `ORCH_STOP_STATUS_ENABLED=false` (аддитивные колонки/терминал-набор инертны при отсутствии отменённых задач). - **Build-cache-pruner: авто-prune docker build cache на mva154** (ORCH-062, `feat`): новый фоновый daemon-поток `src/build_cache_pruner.py` (каркас `disk_watchdog`) — «вторая половина» disk-watchdog (ORCH-063): **watchdog сигналит — pruner убирает**. Устраняет корень инцидента 07.06.2026 (docker build cache ≈11 ГБ → диск mva154 100% → падение self-hosting-конвейера всех проектов) **автоматически, без оператора**. **Аддитивно, never-raise:** `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/`_parse_*`/`src/stage_engine.py`/схема БД — **не тронуты**, новой миграции нет (состояние last-run/last-result — in-memory, best-effort). - **Периодическая уборка (FR-1/AC-1):** каждые `build_cache_prune_interval_s` (дефолт **21600с = 6ч**) тик выполняет **строго `docker builder prune -f --filter until=`** (BuildKit GC). Анти-частота — pure-функция `decide_prune(prev_run_ts, now, interval_s)` (юнит-тестируема без потока/таймера, время инъецируется). Дефолт `until=24h` удерживает тёплый недавний кэш (BR-2/AC-2); `-a/--all` (`build_cache_prune_all`, дефолт `False`) — **только в паре** с возрастным фильтром. - **Self-hosting безопасность (FR-3/AC-3):** команда затрагивает **только** build cache — **нет** `docker image prune`/`docker system prune`, удаления образов/контейнеров запущенных сервисов, остановки/рестарта контейнеров; прод-контейнер `orchestrator` **никогда** не рестартится. Уборка исполняется **на хосте через ssh** (`deploy_ssh_user@deploy_ssh_host`, тот же канал, что `image_freshness`/`self_deploy` — в образе нет docker CLI). Нет ssh-таргета → тик no-op (наблюдаемо в `status().last_error`). diff --git a/CLAUDE.md b/CLAUDE.md index 0d79278..6368e7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,11 +125,18 @@ created → analysis → architecture → development → review → testing → `work_item_id`/`plane_issue_id` → суффикс `#cancelled-`; ADR-001 D4 уточнён: тумбстонится и `plane_issue_id`, т.к. `get_task_by_plane_id`/`create_task_atomic` матчат по нему — иначе re-create коллизирует; исходный UUID парсится из суффикса для аудита). Docs-артефакты (`01..17`) сохраняются. -- **STOP в критичном окне merge/deploy** (ADR-001 D7): `cancel.in_critical_window` (INITIATED-sentinel - self-deploy ORCH-036 / держание merge-lease ORCH-043) → **отложенная** отмена: `tasks.cancel_requested_at`, - снимаются только `queued` job'ы (running-актор деплоя/мержа не трогается), алерт; детерминированный - finalizer (`run_deploy_finalizer`) доводит необратимый шаг до честного исхода и применяет отмену - (`force=True`). STOP **никогда** не трогает `main`/force-push/прод-контейнер/detached-процесс (NFR-3). +- **STOP в критичном окне merge/deploy** (ADR-001 D7): `cancel.in_critical_window` → **отложенная** + отмена: `tasks.cancel_requested_at`, снимаются только `queued` job'ы (running-актор деплоя/мержа не + трогается), алерт; детерминированный finalizer (`run_deploy_finalizer`) доводит необратимый шаг до + честного исхода и применяет отмену (`force=True`). «Критичное окно» = реально начатый необратимый + шаг: INITIATED-sentinel self-deploy (ORCH-036; детач-деплой + поздний `merge_pr` в + `_handle_merge_verify` идут под тем же маркером) **либо** держание merge-lease (ORCH-043) **И** + активно бегущий актор (running-job). **Уточнение P1 (ORCH-090 review):** держание merge-lease в + Phase A на стадии `deploy` в ожидании ручного `Confirm Deploy` БЕЗ бегущего актора **полностью + обратимо** (ничего не смержено/задеплоено) → НЕ критично → немедленный полный сброс (сам отпускает + lease). Иначе отмена откладывалась бы к finalizer'у, который оператор (нажавший STOP именно чтобы НЕ + подтверждать деплой) не запускает — задача застревала бы с удержанным lease, клиня serial-gate репо. + STOP **никогда** не трогает `main`/force-push/прод-контейнер/detached-процесс (NFR-3). - **Кросс-каттинг (adr-0026):** предикат «задача терминальна» расширен `{done}` → `{done, cancelled}` в `serial_gate`/`task_deps`/`stages.py`-сток (иначе отменённая задача заклинит очередь репо); reconciler-терминал-скип уже знал `cancelled` (ORCH-086). `STAGE_TRANSITIONS` exit-гейты рёбер / diff --git a/docs/architecture/README.md b/docs/architecture/README.md index facca2a..546cf70 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -308,10 +308,16 @@ Phase A ждёт ручного `Confirm Deploy`, ORCH-059). ORCH-089 снима > `plane_issue_id` оставил бы отменённую строку «находимой» и заблокировал бы re-create (BR-3/TR-4). > Поэтому он тоже тумбстонится; исходный UUID (== исходный `plane_id` во всех путях создания) парсится > из детерминированного суффикса для аудита. -- **Безопасное прерывание merge/deploy:** STOP в критическом окне (self-deploy `INITIATED`-sentinel - ORCH-036, держание merge-lease ORCH-043/071) → **отложенная отмена** (durable +- **Безопасное прерывание merge/deploy:** STOP в критическом окне → **отложенная отмена** (durable `cancel_requested_at`, отмена только `queued`-job'ов, алерт); необратимый шаг доводится до - честного исхода; `main`/прод-контейнер не трогаются (NFR-3). + честного исхода; `main`/прод-контейнер не трогаются (NFR-3). «Критическое окно» = реально начатый + необратимый шаг: self-deploy `INITIATED`-sentinel (ORCH-036; детач-деплой + поздний `merge_pr` в + `_handle_merge_verify` идут под тем же маркером) **либо** держание merge-lease (ORCH-043/071) **И** + активно бегущий актор (running-job). **P1-уточнение (ORCH-090 review):** удержание merge-lease в + Phase A на `deploy` в ожидании ручного `Confirm Deploy` без бегущего актора **обратимо** → НЕ + критично → немедленный полный сброс (он сам отпускает lease). Иначе deferred-отмена ушла бы к + finalizer'у, который оператор (нажавший STOP, чтобы НЕ подтверждать) никогда не запустит — задача + застряла бы нетерминальной с удержанным lease, клиня serial-gate репо. - **Закрытие дыры релонча:** relaunch в `handle_status_start` ограничен стадией `analysis` (единственный владелец Needs-Input, ORCH-066) — тихий релонч середины пайплайна на старой ветке устранён; единственный вход к запуску — «To Analyse» (`start_pipeline`). diff --git a/src/cancel.py b/src/cancel.py index f30256d..0076f4c 100644 --- a/src/cancel.py +++ b/src/cancel.py @@ -3,8 +3,8 @@ Leaf module mirroring ``src/serial_gate.py`` / ``src/labels.py``: pure, unit-testable, never-raise functions over config + the existing DB / deploy-state. Module-level imports are limited to ``config`` (and ``re``); the critical-window -probe lazily imports ``self_deploy`` / ``merge_gate`` so a cycle can never form and -an import failure degrades safely. +probe lazily imports ``self_deploy`` / ``merge_gate`` / ``db`` so a cycle can never +form and an import failure degrades safely. What it answers: * ``applies(repo)`` — is STOP-cancellation REAL for this repo? @@ -78,16 +78,50 @@ def applies(repo: str) -> bool: return False +def _task_has_running_actor(task_id) -> bool: + """True iff the task currently has a RUNNING job — an active merge/deploy actor. + + Distinguishes a genuinely in-flight merge/deploy (a running deployer / deploy + finalizer job actually executing the irreversible step) from a task merely + PARKED on ``deploy`` awaiting the human ``Confirm Deploy`` (the merge-lease is + held across that wait, ORCH-036/043, but nothing is executing and nothing has + been merged/deployed). Lazily imports ``db``; raises on a db error so the caller + fails CLOSED (treat as critical) rather than silently mis-classifying on doubt. + """ + if not task_id: + return False + from . import db + for job in db.get_active_jobs_for_task(task_id): + if job.get("status") == "running": + return True + return False + + def in_critical_window(task: dict) -> bool: """Is the task inside an irreversible merge/deploy step (ADR-001 D7 / AC-7)? A STOP that lands here must NOT tear the step apart (half-merge / detached prod - deploy / dead prod container, NFR-3). Two markers (existing, no new state): + deploy / dead prod container, NFR-3). Markers (existing, no new state): * self-deploy Phase B initiated — the ``INITIATED`` sentinel in - ``/.deploy-state-//`` (ORCH-036); - * the task currently HOLDS the per-repo merge-lease - ``/.merge-lease-.json`` (ORCH-043), holder branch == task - branch. + ``/.deploy-state-//`` (ORCH-036) — the detached prod + deploy + the deterministic ``merge_pr`` (``_handle_merge_verify``, run later + under the SAME marker) are both covered here; + * the task HOLDS the per-repo merge-lease ``/.merge-lease-.json`` + (ORCH-043), holder branch == task branch, **AND** a merge/deploy actor is + actually RUNNING. + + The merge-lease branch is gated on a running actor on purpose (ORCH-090 review + P1 fix). For the self-hosting repo the lease is HELD from the merge-gate PASS + (``deploy-staging -> deploy`` edge) right through to ``deploy -> done`` — including + the whole time the task sits PARKED on ``deploy`` awaiting a human ``Confirm + Deploy`` (Phase A). That wait is FULLY REVERSIBLE: nothing is merged or deployed + (the irreversible ``merge_pr`` only runs later in ``_handle_merge_verify``, always + under an ``INITIATED`` marker already caught above). Classifying that idle parking + as "critical" used to DEFER the cancel to a deploy finalizer that the operator — + having pressed STOP precisely to NOT confirm — never triggers, so the cancel was + never applied and the task wedged while still holding the lease (blocking the + repo's serial-gate / merges). Now idle parking (lease held, no running actor) is + NOT critical: the full reset runs immediately and itself releases the lease. fail-CLOSED (TR-3): any error/uncertainty -> True (DEFER cancellation). Outside the window -> False (apply the full reset immediately). @@ -108,7 +142,16 @@ def in_critical_window(task: dict) -> bool: from . import merge_gate holder = merge_gate.current_lease_holder(repo) if holder and branch and holder == branch: - return True + # Lease held. Critical ONLY if an actor is actively merging/deploying; + # an idle task parked on `deploy` awaiting Confirm Deploy is reversible. + if _task_has_running_actor(task.get("id")): + return True + logger.info( + "cancel.in_critical_window: task %s holds the merge-lease but no " + "actor is running (idle deploy parking, awaiting Confirm Deploy) -> " + "NOT critical; full reset will release the lease", task.get("id"), + ) + return False except Exception as e: # noqa: BLE001 - fail-CLOSED on doubt logger.warning("cancel.in_critical_window merge-lease probe error: %s", e) return True diff --git a/src/db.py b/src/db.py index 513c712..a458e62 100644 --- a/src/db.py +++ b/src/db.py @@ -867,20 +867,23 @@ def mark_task_cancelled(task_id: int) -> bool: def set_task_cancel_requested(task_id: int) -> bool: """ORCH-090 (ADR-001 D7): mark a deferred cancellation (STOP in critical window). - Idempotent: only stamps ``cancel_requested_at`` the first time. The deterministic - deploy/merge finalizer reads it once the irreversible step completes and then - applies the full cancellation. never-raise -> False on error. + Idempotent: only stamps ``cancel_requested_at`` the first time. Returns the + **first-stamp fact** — ``True`` iff THIS call actually stamped the column (a + repeated STOP while still deferred updates 0 rows -> ``False``), so the caller can + suppress duplicate notifications (AC-6). The deterministic deploy/merge finalizer + reads the column once the irreversible step completes and then applies the full + cancellation. never-raise -> False on error. """ try: conn = get_db() try: - conn.execute( + cur = conn.execute( "UPDATE tasks SET cancel_requested_at=datetime('now') " "WHERE id = ? AND cancel_requested_at IS NULL", (task_id,), ) conn.commit() - return True + return cur.rowcount > 0 finally: conn.close() except Exception: diff --git a/src/stage_engine.py b/src/stage_engine.py index fac7819..267d009 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -1919,20 +1919,24 @@ def cancel_task( # (2) Critical merge/deploy window -> DEFER (unless forced by the finalizer). if not force and cancel_mod.in_critical_window(task): - set_task_cancel_requested(task_id) + first = set_task_cancel_requested(task_id) result["cancelled_jobs"] = cancel_jobs_for_task(task_id, only_queued=True) result["deferred"] = True result["ok"] = True - result["note"] = "deferred-critical-window" - msg = ( - f"⏸️ {link_for(work_item_id)}: STOP получен во время " - f"критичного шага (merge/deploy) — отмена ОТЛОЖЕНА до честного " - f"завершения шага. main/прод не трогаются." - ) - _notify_cancel(work_item_id, task_id, msg) + result["note"] = "deferred-critical-window" if first else "deferred-already-pending" + # AC-6: only alert on the FIRST deferral transition — a repeated STOP while + # still deferred must not spam duplicate Telegram/Plane notifications. + if first: + msg = ( + f"⏸️ {link_for(work_item_id)}: STOP получен во время " + f"критичного шага (merge/deploy) — отмена ОТЛОЖЕНА до честного " + f"завершения шага. main/прод не трогаются." + ) + _notify_cancel(work_item_id, task_id, msg) logger.warning( "cancel_task: task %s (%s) in critical window -> deferred cancel " - "(queued jobs cancelled=%s)", task_id, work_item_id, result["cancelled_jobs"], + "(first=%s, queued jobs cancelled=%s)", task_id, work_item_id, first, + result["cancelled_jobs"], ) return result diff --git a/tests/test_stop_status.py b/tests/test_stop_status.py index 986f8b2..c546acd 100644 --- a/tests/test_stop_status.py +++ b/tests/test_stop_status.py @@ -440,6 +440,72 @@ def test_d7_in_critical_window_detection(monkeypatch): assert cancel_mod.in_critical_window(task) is True +def test_d7_lease_held_idle_parking_is_not_critical(monkeypatch): + """ORCH-090 review P1: a task PARKED on `deploy` awaiting Confirm Deploy holds the + merge-lease but is fully reversible -> NOT a critical window (else the deferred + cancel is never applied and the task wedges).""" + from src import merge_gate + os.makedirs(cfg.settings.repos_dir, exist_ok=True) + branch = "feature/ORCH-432-park" + tid = _make_task("PL-432", "ORCH-432", stage="deploy", branch=branch) + # Lease HELD by this task's branch, NO INITIATED marker, NO running job. + acquired, _ = merge_gate.acquire_merge_lease("orchestrator", branch, "ORCH-432") + assert acquired + assert merge_gate.current_lease_holder("orchestrator") == branch + task = get_task(tid) + assert cancel_mod.in_critical_window(task) is False + + +def test_d7_lease_held_with_running_actor_still_critical(monkeypatch): + """Lease held AND a deploy/merge actor actually running -> still critical (defer).""" + from src import merge_gate + os.makedirs(cfg.settings.repos_dir, exist_ok=True) + branch = "feature/ORCH-433-merge" + tid = _make_task("PL-433", "ORCH-433", stage="deploy", branch=branch) + merge_gate.acquire_merge_lease("orchestrator", branch, "ORCH-433") + _make_job(tid, status="running", pid=9191) # the merge/deploy actor + task = get_task(tid) + assert cancel_mod.in_critical_window(task) is True + + +def test_d7_stop_on_deploy_awaiting_confirm_full_resets(monkeypatch): + """End-to-end of the P1 fix: STOP while parked on `deploy` awaiting Confirm Deploy + -> immediate FULL reset (terminal cancelled, branch deleted, lease released).""" + calls = _stub_full_reset(monkeypatch) + from src import merge_gate + os.makedirs(cfg.settings.repos_dir, exist_ok=True) + branch = "feature/ORCH-434-park" + tid = _make_task("PL-434", "ORCH-434", stage="deploy", branch=branch) + merge_gate.acquire_merge_lease("orchestrator", branch, "ORCH-434") + + res = stage_engine.cancel_task(tid) + + assert res["ok"] and not res["deferred"] + assert res["note"] == "cancelled" + # Durable terminal + branch deleted -> repo no longer wedged. + assert get_task(tid)["stage"] == "cancelled" + assert calls["branch"], "full reset deletes the remote feature branch" + # The held lease was released (step 3c) -> the repo's serial-gate is unblocked. + assert merge_gate.current_lease_holder("orchestrator") is None + + +def test_d7_repeated_stop_in_critical_window_no_duplicate_notify(monkeypatch): + """AC-6 / P2: a repeated STOP while still deferred does not re-notify.""" + _stub_full_reset(monkeypatch) + from src import self_deploy + notifies = [] + monkeypatch.setattr(stage_engine, "_notify_cancel", + lambda *a, **k: notifies.append(a), raising=True) + tid = _make_task("PL-435", "ORCH-435", stage="deploy", branch="feature/ORCH-435-d") + self_deploy.write_marker("orchestrator", "ORCH-435", self_deploy.INITIATED, content="1") + + r1 = stage_engine.cancel_task(tid) + r2 = stage_engine.cancel_task(tid) + assert r1["deferred"] and r1["note"] == "deferred-critical-window" + assert r2["deferred"] and r2["note"] == "deferred-already-pending" + assert len(notifies) == 1, "only the first deferral transition notifies" + + def test_d7_deferred_applied_by_finalizer(monkeypatch): """After the irreversible step finishes, the finalizer applies the deferred cancel.""" calls = _stub_full_reset(monkeypatch) -- 2.49.1 From 07190f69f5e80590e836d721f958bb533fcf3174 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 21:25:43 +0300 Subject: [PATCH 7/9] reviewer(ET): auto-commit from reviewer run_id=501 --- docs/work-items/ORCH-090/12-review.md | 159 +++++++++++--------------- 1 file changed, 69 insertions(+), 90 deletions(-) diff --git a/docs/work-items/ORCH-090/12-review.md b/docs/work-items/ORCH-090/12-review.md index 9e35400..cf83edf 100644 --- a/docs/work-items/ORCH-090/12-review.md +++ b/docs/work-items/ORCH-090/12-review.md @@ -1,34 +1,47 @@ --- -verdict: REQUEST_CHANGES +verdict: APPROVED work_item: ORCH-090 stage: review author_agent: reviewer -status: changes-requested +status: approved created_at: 2026-06-09 model_used: claude-opus-4-8 type: review work_item_id: ORCH-090 -version: 1 +version: 2 --- -# Review ORCH-090 — Механизм отмены задачи: статус STOP +# Review ORCH-090 — Механизм отмены задачи: статус STOP (re-review, attempt 2) ## Summary -Реализация STOP-отмены сделана аккуратно, по канону прошлых задач (leaf `src/cancel.py` -never-raise, kill-switch `stop_status_enabled`, аддитивные миграции, fail-closed маршрутизация по -образцу `confirm_deploy`/ORCH-059). Кросс-каттинг `{done}` → `{done, cancelled}` проведён -исчерпывающе и консистентно (serial_gate / task_deps / stages / db / job_reaper / queue_worker), в -точности по adr-0026. Документация — golden-source качества (README, architecture/README, -internals, CHANGELOG, CLAUDE.md, .env.example, оба ADR, все номерные доки). Полный регресс -`pytest tests/` зелёный (1345 passed); новый `tests/test_stop_status.py` покрывает AC-1…AC-10 + D7. +Повторный review после фикса блокирующего P1 из предыдущей итерации (`12-review.md` v1). +Реализация STOP-отмены аккуратна и канонична (leaf `src/cancel.py` never-raise, kill-switch +`stop_status_enabled`, fail-closed маршрутизация по образцу `confirm_deploy`/ORCH-059, аддитивные +идемпотентные миграции). Кросс-каттинг `{done}` → `{done, cancelled}` проведён исчерпывающе и +консистентно (serial_gate / task_deps / stages / db / job_reaper / queue_worker), в точности по +adr-0026. -**Однако** обнаружен **P1** дефект в логике безопасного прерывания (AC-7 / D7): «критическое окно» -определено слишком широко и **отменённая задача self-hosting-репо застревает (wedge)** в самом -вероятном сценарии STOP — отмене задачи, ожидающей `Confirm Deploy`. Это блокирует приёмку. +**Оба ранее блокировавших/важных дефекта закрыты и покрыты содержательными тестами:** +- **P1 (был blocker) — ИСПРАВЛЕН.** `cancel.in_critical_window` сужен: удержание merge-lease без + бегущего актора (`_task_has_running_actor`) на стадии `deploy` в ожидании `Confirm Deploy` теперь + НЕ считается критическим окном → немедленный полный сброс, который сам отпускает lease (шаг 3c). + Тесты `test_d7_lease_held_idle_parking_is_not_critical`, + `test_d7_lease_held_with_running_actor_still_critical`, + `test_d7_stop_on_deploy_awaiting_confirm_full_resets` (последний прямо проверяет + `stage='cancelled'` + удалённую ветку + `current_lease_holder is None`). Сверено по коду + `src/cancel.py::in_critical_window` (стр. 100–158) и `stage_engine.cancel_task` — wedge + self-hosting-репо устранён. +- **P2 (был should-fix) — ИСПРАВЛЕН.** Deferred-ветка `cancel_task` шлёт алерт только при первом + переходе (`first = set_task_cancel_requested(...)`, далее `if first:`); повторный STOP в + критическом окне даёт `deferred-already-pending` без повторного уведомления. Тест + `test_d7_repeated_stop_in_critical_window_no_duplicate_notify` (ровно 1 notify). -Оси проверки: ✅ ТЗ (кроме AC-7 — см. P1) · ⚠️ ADR (D7 реализован у́же, чем намеренная семантика) · -✅ качество кода · ✅ документация. +Полный регресс `pytest tests/` зелёный (**1349 passed**); `tests/test_stop_status.py` — 30 кейсов +(TC-01…TC-14 + D7), покрывают AC-1…AC-10 и оба фикса. + +Оси проверки: ✅ ТЗ/AC (AC-1…AC-10, включая ранее проваленный AC-7) · ✅ ADR (соответствие +adr-0026/ADR-001; см. P2-нит ниже) · ✅ качество кода · ✅ документация. ## Findings @@ -36,100 +49,66 @@ internals, CHANGELOG, CLAUDE.md, .env.example, оба ADR, все номерны - (нет) ### P1 — Must fix - -- [ ] **Deferred-cancel НИКОГДА не применяется при STOP на стадии `deploy` в ожидании «Confirm - Deploy» → отменённая задача self-hosting-репо застревает и заклинивает очередь репо.** (AC-7 / - ADR-001 D7 / adr-0026; NFR-3 self-hosting). - - **Где:** `src/cancel.py::in_critical_window` (merge-lease ветка, стр. 107–114) + - `src/stage_engine.py::cancel_task` deferred-ветка (стр. ~1920–1937) + единственная точка - применения deferred-cancel в `run_deploy_finalizer` (стр. 1659–1679). - - **Сценарий (сверено по коду):** для self-hosting-репо merge-gate на ребре `deploy-staging → - deploy` держит merge-lease на PASS и **не отпускает** его до `deploy→done` / rollback (см. - `src/qg/checks.py::check_branch_mergeable`, return True на стр. 701 и 711 — без - `release_merge_lease`; подтверждено docstring'ами `_handle_merge_gate` стр. 906–907 и - `_handle_merge_verify` стр. 1132–1133). Phase A (`_handle_self_deploy_phase_a`) переводит - задачу на стадию `deploy` и ждёт ручного «Confirm Deploy», **продолжая держать lease**; - sentinel `INITIATED` ещё НЕ записан (он пишется в Phase B, после Confirm Deploy). - - Поэтому при STOP в этом состоянии `in_critical_window` возвращает `True` **только** по признаку - «держим merge-lease» → `cancel_task` уходит в **deferred**-ветку: ставит `cancel_requested_at`, - снимает лишь `queued`-job'ы (их нет), шлёт алерт и **возвращается, не отпустив lease** (выход - до шага 3c). Running-актора деплоя в Phase A нет, `run_deploy_finalizer` запускается **только** - после Phase B (Confirm Deploy), которого оператор намеренно не делает (он нажал STOP). Итог: - единственная точка применения deferred-cancel недостижима → **отмена не применяется никогда**. - - **Последствия:** (1) интент оператора молча теряется — задача НЕ отменяется (ровно тот класс - «ручной хирургии», который фича призвана устранить, BRD §1 / инцидент ORCH-087); (2) задача - остаётся на `deploy` (нетерминальна) и **держит merge-lease** → serial-gate (ORCH-088) считает - репо «активным» и блокирует вход новых задач в `analysis`, а merge-lease блокирует мержи всего - репо — до stale-reclaim ORCH-065 (частично) / ручного вмешательства. Это **самый частый - реальный сценарий STOP для self-hosting** («не катить в прод — отменить»), и фича в нём - ломается. - - **Причина:** «критическое окно» (необратимый шаг) переопределено как «держим merge-lease», но - Phase-A-ожидание Confirm Deploy **полностью обратимо** (ничего не смержено и не задеплоено). - По смыслу D7 необратимость = реально начатый необратимый шаг (sentinel `INITIATED` Phase B / - идущий merge), а не сам факт удержания lease в ожидании человека. - - **Как чинить (на выбор developer/architect, не предписываю реализацию):** сузить - `in_critical_window` так, чтобы удержание merge-lease на стадии `deploy` БЕЗ маркера `INITIATED` - и без идущего merge не считалось критическим окном (т.е. STOP в ожидании Confirm Deploy = - немедленный полный сброс, который сам отпустит lease в шаге 3c); ЛИБО добавить применение - deferred-cancel в точку, гарантированно достижимую и для merge-lease-окна (как и заявляет - ADR-001 D7: «`run_deploy_finalizer` Phase C / **`_handle_merge_verify`**» — второй финализатор в - коде не задействован). Любой вариант обязан: не оставлять задачу нетерминальной с удержанным - lease; покрыть тест-кейсом «STOP на `deploy` в ожидании Confirm Deploy → задача `cancelled`, - lease отпущен, репо разблокирован». - - **Тест-разрыв:** D7-кейсы (`test_d7_*`) покрывают только окно с `INITIATED`-маркером; окно - «держим lease, ждём Confirm Deploy» не покрыто — отсюда дефект и прошёл. +- (нет) ### P2 — Should fix -- [ ] **Deferred-ветка не идемпотентна по уведомлениям (AC-6 «нет Telegram-спама дублями»).** - `src/stage_engine.py::cancel_task` — терминальная идемпотентность (`stage in {done,cancelled}`) - защищает full-reset путь, но в критическом окне повторный STOP каждый раз заново вызывает - `set_task_cancel_requested` (идемпотентно) **и** `_notify_cancel` (Telegram + Plane-коммент) — - повторные STOP во время merge/deploy спамят дублирующими уведомлениями. Предложение: слать алерт - «отложено» лишь при первом переходе `cancel_requested_at IS NULL → NOW` (его уже умеет различать - `set_task_cancel_requested`, возвращая факт первой простановки). +- [ ] **Work-item ADR-001 §D7 не синхронизирован с фиксом P1 (running-actor-уточнение).** + `docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md` §D7 (стр. 189–201) по-прежнему + определяет критическое окно как «задача держит merge-lease … / merge в процессе» — **без** + оговорки «И активно бегущий актор», которую фактически реализует код + (`cancel.in_critical_window` + `_task_has_running_actor`) после фикса P1. Авторитетные + golden-source доки уже синхронизированы (`CLAUDE.md` — абзац «Уточнение P1 (ORCH-090 review)»; + `docs/architecture/README.md` стр. 316–317 «P1-уточнение»; `CHANGELOG.md` — буллет «Фикс P1»), + поэтому витрина проекта корректна и это **не** P0 «src изменён, доки не обновлены». Но per + «documentation = golden source» работа-айтемный ADR (запись именно этого архитектурного решения) + должен честно отражать итоговую семантику — как это уже сделано для уточнения D4. Предложение: + добавить в §D7 строку-уточнение «merge-lease критичен ТОЛЬКО при бегущем акторе; припаркованное + ожидание `Confirm Deploy` обратимо → немедленный сброс» (ссылка на review P1). Не блокирует. ### P3 — Nice to have - [ ] **«Завис» `cancel_requested_at` на успешно задеплоенной задаче → вечный `pending` в - `GET /queue`.** При SUCCESS-деплое `run_deploy_finalizer` вызывает `cancel_task(force=True)`, - который видит `stage='done'` → «already-terminal» no-op и **не очищает** `cancel_requested_at`. - `db.cancelled_tasks_snapshot` считает `pending = cancel_requested_at IS NOT NULL AND stage != - 'cancelled'` → done-задача с бывшим deferred-STOP навсегда показывается «pending». Чисто - наблюдаемость; предложение — очищать `cancel_requested_at` при честном no-op после завершения. + `GET /queue`** (перенесено из v1, не адресовано). При SUCCESS-деплое `run_deploy_finalizer` + вызывает `cancel_task(force=True)`, который видит `stage='done'` → «already-terminal» no-op и + **не очищает** `cancel_requested_at`; `db.cancelled_tasks_snapshot` считает + `pending = cancel_requested_at IS NOT NULL AND stage != 'cancelled'` → done-задача с бывшим + deferred-STOP навсегда показывается «pending». Чисто наблюдаемость; предложение — очищать + `cancel_requested_at` при честном no-op после завершения. - [ ] **adr-0026 п.6 (post-deploy monitor «не тикает по отменённой задаче») в коде не реализован** - (`run_post_deploy_monitor` не сверяет терминал задачи). Фактически безвреден и недостижим: - post-deploy наблюдение идёт только ПОСЛЕ `done`, а STOP на `done` — no-op, поэтому отменённая - задача в монитор не попадает. Рекомендация: либо снять пункт из adr-0026 как нерелевантный, либо + (перенесено из v1). Фактически безвреден и недостижим: post-deploy наблюдение идёт только ПОСЛЕ + `done`, а STOP на `done` — no-op. Рекомендация: снять пункт из adr-0026 как нерелевантный либо добавить дешёвый терминал-гард для строгого соответствия ADR. -- [ ] Косметика: «рваная» строковая склейка комментария relaunch-hole в - `src/webhooks/plane.py` (стр. 345–350) — собрать в одну строку для читаемости. +- [ ] **Косметика:** «рваная» строковая склейка комментария relaunch-hole в + `src/webhooks/plane.py` (стр. 345–351) — собрать в одну строку для читаемости. ## Документация -**Обновлена полностью и качественно — отдельных findings нет.** Проверено пофайльно: -- `README.md` — таблица env (`ORCH_STOP_STATUS_ENABLED`/`ORCH_STOP_STATUS_REPOS`), новый раздел - «Отмена задачи: статус STOP», обновлён список job-статусов (`cancelled`), инфра-предусловие. -- `docs/architecture/README.md` — новый раздел «STOP / отмена задачи (реализовано)», обновлены - раздел «База данных» (колонки/тумбстон/статусы) и таблица API (`/queue` несёт блок `stop`). +**Обновлена полностью и качественно — отдельных blocking-findings нет.** Проверено пофайльно: +- `README.md` — таблица env (`ORCH_STOP_STATUS_ENABLED`/`ORCH_STOP_STATUS_REPOS`), раздел «Отмена + задачи: статус STOP (ORCH-090)», обновлён список job-статусов (`cancelled`), инфра-предусловие. +- `docs/architecture/README.md` — раздел STOP со статусом «реализовано», блок `stop` в `/queue`, + раздел «База данных» (колонки/тумбстон/статусы) **и P1-уточнение** (стр. 316–317). - `docs/architecture/internals.md` — `STAGE_TRANSITIONS` (сток `cancelled`), терминал-предикат `{done,cancelled}`, job-статусы. -- `CHANGELOG.md` (`feat:`), `CLAUDE.md` (раздел «Отмена задачи: статус STOP (ORCH-090)»), - `.env.example` — присутствуют и согласованы. +- `CHANGELOG.md` (`feat:` + отдельный буллет «Фикс P1»), `CLAUDE.md` (раздел «Отмена задачи: статус + STOP (ORCH-090)» с абзацем «Уточнение P1»), `.env.example` — согласованы. - ADR: локальный `06-adr/ADR-001-stop-cancel-task.md` + сквозной `docs/architecture/adr/adr-0026-stop-cancel-task.md`; уточнение D4 (тумбстон `plane_issue_id`) - честно отражено и в коде, и в доках. Все номерные доки `01..10` на месте. -- Раздела README «Известные ограничения», который ORCH-090 закрывал бы (ORCH-079), нет — - обзорная витрина не рассинхронена. + отражено в коде и доках. Единственный gap — §D7 локального ADR не дотянут до running-actor-фикса + (P2 выше). +- Раздела README «Известные ограничения», который ORCH-090 закрывал бы (ORCH-079), нет — обзорная + витрина не рассинхронена. -Трассировка маркеров (TRACEABILITY.md): правки маркированных инвариантов `serial_gate`/ORCH-088 и -`task_deps`/ORCH-026 сверены с их ADR — расширение терминал-набора до `{done,cancelled}` сохраняет +**Трассировка маркеров (TRACEABILITY.md):** правки маркированных инвариантов `serial_gate`/ORCH-088 +и `task_deps`/ORCH-026 сверены с их ADR — расширение терминал-набора до `{done,cancelled}` сохраняет FIFO-семантику (`t2.id < jobs.task_id`) и dep-готовность (терминальный предшественник), инварианты не сломаны. `STAGE_TRANSITIONS` exit-гейты / `QG_CHECKS` / `check_*` — не тронуты (подтверждено анти-регресс-снапшотами, зелёные). ## Вердикт -`REQUEST_CHANGES` — из-за P1 (deferred-cancel недостижим при STOP в ожидании Confirm Deploy → -wedge self-hosting-репо). Остальное (P2/P3) — на усмотрение, но P1 обязателен к исправлению с -покрывающим тест-кейсом перед повторным review. +`APPROVED` — оба ранее найденных дефекта (P1 wedge при STOP в ожидании Confirm Deploy; P2 +дубль-уведомления в deferred-ветке) исправлены и покрыты содержательными тестами; полный регресс +зелёный (1349 passed). Остаются только P2 (синхронизация §D7 локального ADR) и P3 (наблюдаемость/ +косметика) — не блокируют приёмку, желательны к устранению попутно. -- 2.49.1 From 5ca9b8fd62db53f8ab9d7ca8c42986b487584ed3 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 21:27:56 +0300 Subject: [PATCH 8/9] tester(ET): auto-commit from tester run_id=502 --- docs/work-items/ORCH-090/13-test-report.md | 95 ++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 docs/work-items/ORCH-090/13-test-report.md diff --git a/docs/work-items/ORCH-090/13-test-report.md b/docs/work-items/ORCH-090/13-test-report.md new file mode 100644 index 0000000..3a2039d --- /dev/null +++ b/docs/work-items/ORCH-090/13-test-report.md @@ -0,0 +1,95 @@ +--- +result: PASS +work_item: ORCH-090 +stage: testing +author_agent: tester +status: pass +created_at: 2026-06-09 +model_used: claude-opus-4-8 +type: test-report +work_item_id: ORCH-090 +--- + +# Test Report — ORCH-090 — Механизм отмены задачи: статус STOP (остановка + полный сброс) + +> Машинный вердикт читается ТОЛЬКО из frontmatter (`result:`). Гейт `check_tests_passed` +> (`_parse_tests_verdict`) парсит его. Review-вердикт предшественника — `APPROVED` (`12-review.md` v2). + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Дата: 2026-06-09 +- Worktree: `feature/ORCH-090-stop-plane` (`/repos/_wt/orchestrator/feature_ORCH-090-stop-plane/`) +- Прод-контейнер `orchestrator` (8500) не трогался (smoke только read-only). + +## Результаты + +### Полный регресс +`pytest tests/ -q` (из worktree ветки задачи) — **1349 passed, 1 warning** (37.91s). +Warning — известный pydantic v2 deprecation в `src/config.py:8` (не относится к ORCH-090, не регресс). +`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` анти-регресс-снапшоты — зелёные (NFR-1). + +### Профильные сюиты +`pytest tests/test_stop_status.py -v` — **30 passed** (1.72s): TC-01…TC-14 + 7 кейсов D7 +(безопасное прерывание merge/deploy, P1-фикс «merge-lease критичен только при бегущем акторе», +P2-фикс «нет дубль-уведомлений в deferred-ветке»). + +### Smoke API (read-only, прод 8500) +| Проверка | Результат | +|----------|-----------| +| `GET /health` | `{"status":"ok","service":"orchestrator"}` — OK | +| `GET /status` | OK (active_tasks отдаётся; ORCH-090 видна на `testing`) | +| `GET /queue` → блок `serial_gate` (ORCH-088) | присутствует — OK | +| `GET /queue` → блок `auto_labels` (ORCH-089) | присутствует — OK | + +> Блок `stop` (ORCH-090) в проде 8500 отсутствует — ожидаемо: фича этой задачи ещё не задеплоена +> (прод несёт предыдущий образ). В коде ветки блок присутствует (`src/main.py:198 "stop": cancel.snapshot()`) +> и покрыт тестом `test_tc09_queue_has_stop_block_and_keeps_keys` — это НЕ регресс смока. + +## Сопоставление с тест-планом (`04-test-plan.yaml`) + +| TC ID | Описание | Тест-функция(и) | Результат | +|-------|----------|-----------------|-----------| +| TC-01 | STOP распознаётся/маршрутизируется; прочее → no-op, never-raise | `test_tc01_stop_routed_and_unknown_is_noop` | PASS | +| TC-02 | Остановка агента: SIGTERM по `jobs.pid` через каскад `_watchdog`; idle → no-op | `test_tc02_stop_active_agent_by_pid`, `test_tc02_idle_agent_no_stop` | PASS | +| TC-03 | Отмена job'ов: queued+running → терминал; `claim_next_job` их не выбирает | `test_tc03_jobs_cancelled_and_claim_skips`, `test_tc03_cancel_jobs_helper_only_queued` | PASS | +| TC-04 | Запрет авто-requeue: `_finalize`/reaper не возвращают в `queued` | `test_tc04_reaper_does_not_requeue_terminal_task` | PASS | +| TC-05 | Полный сброс: `remove_worktree`+удаление ветки; `main` не тронут, нет force-push | `test_tc05_full_reset_removes_branch_and_worktree`, `test_tc05_delete_remote_branch_refuses_main` | PASS | +| TC-06 | Docs-артефакты (01..17) сохраняются при сбросе | `test_tc06_docs_and_task_row_survive` | PASS | +| TC-07 | Идемпотентность: повторный STOP на cancelled/done/missing → no-op | `test_tc07_idempotent_on_cancelled_done_missing` | PASS | +| TC-08 | Kill-switch `stop_status_enabled=False` нейтрален; `True` → отмена; scope CSV | `test_tc08_kill_switch_off_inert`, `test_tc08_kill_switch_off_handle_stop_noop`, `test_tc08_scope_csv` | PASS | +| TC-09 | Наблюдаемость: `GET /queue` несёт блок `stop`; never-raise при ошибке | `test_tc09_queue_has_stop_block_and_keeps_keys`, `test_tc09_snapshot_never_raises` | PASS | +| TC-10 | Дыра релонча закрыта: ручной перевод в mid-стадию НЕ порождает job | `test_tc10_relaunch_hole_closed_midpipeline` | PASS | +| TC-11 | Единственный вход — To Analyse → `start_pipeline`; analysis idle релончит analyst | `test_tc11_new_task_starts_pipeline`, `test_tc11_analysis_idle_relaunches_analyst` | PASS | +| TC-12 | Терминал-скип/restart-safe: reconciler F-1 и reaper не оживляют cancelled | `test_tc12_reconciler_skips_cancelled`, `test_tc12_requeue_running_does_not_revive_cancelled` | PASS | +| TC-13 | End-to-end STOP: агент остановлен, job'ы отменены, ветка убрана, статус durable, уведомления | `test_tc13_end_to_end_stop` | PASS | +| TC-14 | Аддитивность БД: миграция идемпотентна; существующие контракты целы | `test_tc14_migration_idempotent_and_columns_present`, `test_tc14_existing_contracts_intact` | PASS | +| — (D7) | Безопасное прерывание merge/deploy + P1/P2-фиксы | `test_d7_*` (7 кейсов) | PASS | + +Все 14 TC из тест-плана выполнены и сопоставлены; ожидаемый `expected: PASS` совпадает с фактом. + +## Сопоставление с критериями приёмки (`03-acceptance-criteria.md`) + +| AC | Критерий | Покрытие | Результат | +|----|----------|----------|-----------| +| AC-1 | STOP останавливает активного агента (SIGTERM-каскад по `jobs.pid`) | TC-02, TC-13 | PASS | +| AC-2 | Все job'ы отменены без авто-requeue (claim не выбирает) | TC-03, TC-04 | PASS | +| AC-3 | Таймеры/мониторы сняты; отменённая задача не реконсилируется | TC-12 | PASS | +| AC-4 | Полный сброс: ветка/worktree убраны, прогресс durable, docs сохранены | TC-05, TC-06, TC-13 | PASS | +| AC-5 | Единственный вход — To Analyse; дыра релонча закрыта | TC-10, TC-11 | PASS | +| AC-6 | Идемпотентность STOP (cancelled/done/missing) | TC-07 | PASS | +| AC-7 | Безопасное прерывание merge/deploy (нет half-merge/рестарта прода/force-push) | TC-05, D7 (`test_d7_*`) | PASS | +| AC-8 | Kill-switch и нулевая регрессия (полный pytest зелёный) | TC-08, полный регресс 1349 passed | PASS | +| AC-9 | Аддитивность БД и restart-safe | TC-14, TC-12 | PASS | +| AC-10 | Наблюдаемость STOP (`GET /queue` блок, уведомления) | TC-09, TC-13 | PASS | + +Все AC-1…AC-10 покрыты и зелёные. + +## Итог + +**PASS.** Полный регресс зелёный (1349 passed), профильная сюита `tests/test_stop_status.py` зелёная +(30 passed), smoke read-only OK (`/health`, `/status`, `/queue` с блоками `serial_gate`/`auto_labels`), +каждый TC тест-плана выполнен и сопоставлен с AC. Регрессов (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`, +авто-requeue, оживание отменённой задачи, касание `main`/прод-контейнера) не обнаружено. + +`result: PASS` → задача переходит на `deploy-staging`. -- 2.49.1 From 08e6bfc3d5d7705b818cab8808934750c1bf35b1 Mon Sep 17 00:00:00 2001 From: deploy-finalizer Date: Tue, 9 Jun 2026 21:36:11 +0300 Subject: [PATCH 9/9] deploy(ORCH-036): finalize SUCCESS for ORCH-090 --- docs/work-items/ORCH-090/14-deploy-log.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/work-items/ORCH-090/14-deploy-log.md diff --git a/docs/work-items/ORCH-090/14-deploy-log.md b/docs/work-items/ORCH-090/14-deploy-log.md new file mode 100644 index 0000000..24729e4 --- /dev/null +++ b/docs/work-items/ORCH-090/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-090 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. -- 2.49.1