fix(cancel): narrow STOP critical-window so deploy-park cancel applies (ORCH-090)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 21:19:28 +03:00
committed by orchestrator-deployer
parent 46c59bad99
commit aae65969d5
7 changed files with 162 additions and 32 deletions

View File

@@ -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-<id>`. 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=<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`).

View File

@@ -125,11 +125,18 @@ created → analysis → architecture → development → review → testing →
`work_item_id`/`plane_issue_id` → суффикс `#cancelled-<id>`; 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-гейты рёбер /

View File

@@ -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`).

View File

@@ -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
``<repos_dir>/.deploy-state-<repo>/<wi>/`` (ORCH-036);
* the task currently HOLDS the per-repo merge-lease
``<repos_dir>/.merge-lease-<repo>.json`` (ORCH-043), holder branch == task
branch.
``<repos_dir>/.deploy-state-<repo>/<wi>/`` (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 ``<repos_dir>/.merge-lease-<repo>.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

View File

@@ -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:

View File

@@ -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

View File

@@ -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)