From aae65969d51c75ffd5f2711474af3e7a00490890 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 21:19:28 +0300 Subject: [PATCH] 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)