architect(ET): auto-commit from architect run_id=497

This commit is contained in:
2026-06-09 20:37:21 +03:00
committed by orchestrator-deployer
parent 96a99a09b7
commit ab083ba826
7 changed files with 609 additions and 1 deletions

View File

@@ -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` (строки 215306): при существующей задаче без
активного 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` (стр. 129173),
сравнивает `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, стр. 661718); PID задачи стампится в
`jobs.pid` (`_spawn`, стр. 607614).
- Статусы job в `jobs``queued | running | done | failed` (`src/db.py`, стр. 5672); claim
выбирает только `status='queued'` (`claim_next_job`, стр. 586651). Реквью на dead-running —
`job_reaper._reap_unknown_outcome` (`attempts<max → queued`, иначе `failed`, стр. 315334).
- **Терминал-скип уже учитывает `cancelled`:** `reconciler._is_terminal_state` (group
`completed`/`cancelled` или логический ключ `cancelled`, стр. 398415) и F-1 пропускает
`stage in ("done","cancelled")` ДО любой работы (стр. 196, ORCH-086 D2 — `cancelled`-стадия
**уже предвосхищена**).
- **Но** «незавершённость» задачи в горячем планировщике определена как `stage != 'done'`
(БЕЗ `cancelled`) в `src/serial_gate.py` (стр. 115, 120, 270, 334) и `src/task_deps.py`
(`stage != 'done'`). Новая терминальная стадия `cancelled`, не распознанная здесь, **заклинит
очередь** репо (serial-gate сочтёт отменённую задачу «активной»; task-deps — «незавершённой
зависимостью»).
- `remove_worktree(repo, branch)` — never-raise локальная очистка (`src/git_worktree.py`,
стр. 98107); функции удаления Gitea-ветки **нет**.
- Запуск с нуля — `handle_status_start → start_pipeline` (ветка + docs + analyst, стр. 430626);
`create_task_atomic` с anti-dup по `plane_id`; uniqueness-guard по `work_item_id`
(`ensure_unique_work_item_id`).
Требуется единый, декларативный, обратимый, аддитивный механизм под kill-switch, never-raise,
restart-safe; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*` — без изменений (TRZ §1, NFR-1).
---
## Решение
### Сводка
Ввести **STOP** как сигнал отмены задачи: новый логический Plane-ключ `stop` (fail-closed, по
образцу `confirm_deploy`/ORCH-059), маршрутизируемый в новый обработчик `handle_stop`. Обработчик
переводит задачу в **новое системное терминальное состояние `cancelled`** (стадия + durable),
останавливая активного агента существующим graceful-каскадом, отменяя все job'ы новым
терминальным исходом `jobs.status='cancelled'`, снимая таймеры/мониторы, удаляя рабочую
ветку+worktree (docs сохраняются) и **тумбстоня** натуральные ключи (`plane_id`/`work_item_id`),
чтобы повторный «To Analyse» создал задачу с нуля. Параллельно закрывается дыра релонча:
relaunch в `handle_status_start` ограничивается единственным легитимным владельцем Needs-Input —
стадией `analysis`. Чистая логика — leaf `src/cancel.py` (never-raise); оркестрация —
`stage_engine.cancel_task`. Всё под флагом `stop_status_enabled`.
**Ключевой кросс-каттинг (см. adr-0026):** системный предикат «задача терминальна» расширяется с
`{done}` до `{done, cancelled}` в трёх горячих местах планировщика (serial-gate, task-deps,
`stages.py`-сток), приводя их в соответствие с уже существующим терминал-скипом реконсилятора.
### D1 — Распознавание и маршрутизация STOP (FR-1, BR-1, BR-5)
- В `_PLANE_NAME_TO_KEY` добавить `"STOP" → "stop"`. **В `_DEFAULT_STATES` ключ `stop` НЕ
добавляется** — fail-closed по образцу ORCH-059: нет UUID-фолбэка для enduro/API-сбоя →
`get_project_states(...).get("stop")` вернёт `None` → ветка просто не активируется (нет
`KeyError`, нет слепой отмены). Инфра-предусловие — создать статус STOP на доске ORCH с
**группой `cancelled`** (07-infra-requirements.md), чтобы терминал-скип по группе работал
нативно.
- `handle_issue_updated`: добавить ветку `stop_state = proj_states.get("stop")`
`elif stop_state and new_state == stop_state: await handle_stop(data, project_id)`. Ставится
**до** `to_analyse`/`approved`/`rejected`, чтобы жесты не алиасили.
- `handle_stop` (новый, в `plane.py`): резолвит задачу по `get_task_by_plane_id`; делегирует в
`stage_engine.cancel_task(task_id, …)`. Гард kill-switch + repo-scope через `cancel.applies(repo)`.
- **Идемпотентность (BR-5):** если задача отсутствует / уже `stage in ("done","cancelled")`
no-op (без повторного kill/удаления/уведомления). Контракт — never-raise (NFR-5): ошибка
логируется, вебхук-поток не падает.
### D2 — Остановка активного агента (FR-2, BR-1a)
Переиспользовать существующий graceful-каскад, **не изобретать новый kill**. Для running-job'а
задачи взять `jobs.pid` и послать `SIGTERM` через путь `launcher._watchdog`
(SIGTERM → grace `agent_kill_grace_seconds` → SIGKILL). Вынести из `_watchdog` переиспользуемый
хелпер `launcher.stop_process(pid, run_id)` (тот же каскад + `_record_kill`), вызываемый и из
cancel-пути. Нет активного процесса (idle/queued) → шаг no-op. **Никогда** не убивать
detached-процесс self-deploy (см. D7).
### D3 — Отмена job'ов и запрет авто-requeue (FR-3, BR-1b/1c)
- Новый **терминальный** статус job `jobs.status='cancelled'`. Схема не меняется (`status` — TEXT);
расширяется лишь набор допустимых значений → `queued|running|done|failed|cancelled`.
- Хелпер `db.cancel_jobs_for_task(task_id)` — guarded UPDATE
`SET status='cancelled', finished_at=datetime('now') WHERE task_id=? AND status IN ('queued','running')`.
`mark_job` расширяется: `cancelled` тоже стампит `finished_at` (в наборе с `done|failed`).
- **claim не трогается:** `claim_next_job` выбирает только `status='queued'``cancelled`
исключён нативно (NFR-6 — без новых JOIN'ов в горячий путь).
- **Запрет авто-requeue (race-safe):** `job_reaper._reap_unknown_outcome` и launch-error requeue
в `queue_worker` ПЕРЕД реквью читают терминальное состояние задачи; `stage in
("done","cancelled")` → job помечается `cancelled` (терминал), **без** возврата в `queued`.
Это закрывает гонку «SIGTERM послан, job ещё `running`, reaper видит dead-pid → реквью».
Источник истины «не оживлять» — **стадия задачи `cancelled`**, а не статус job.
### D4 — Durable терминал задачи + переиспользование ключей (FR-5, NFR-4, BR-2/BR-3)
- Durable-состояние = **`tasks.stage='cancelled'`**. Это уже понимается терминал-скипом
реконсилятора (стр. 196) → **ноль нового кода** для NFR-4; после рестарта отменённая задача не
оживает (`requeue_running_jobs` флипает только `running`, а job'ы — `cancelled`).
- Аддитивная колонка `tasks.cancelled_at TEXT` (через `_ensure_column`) — durable-метка
времени для аудита/наблюдаемости.
- **Переиспользование натуральных ключей (BR-3):** чтобы повторный «To Analyse» создал задачу с
нуля, на cancel выполняется **тумбстон** ключей отменённой строки:
`plane_id := plane_id || '#cancelled-' || id`, `work_item_id := work_item_id || '#cancelled-' || id`.
Тогда `get_task_by_plane_id(plane_id)` вернёт `None``start_pipeline` создаст свежую задачу
(новая ветка от актуального `origin/main`, новый analyst); anti-dup `create_task_atomic` и
`ensure_unique_work_item_id` не коллизируют. Поле `plane_issue_id` **сохраняется** нетронутым —
аудит-связь с issue Plane не теряется. Строка `tasks` **не удаляется** (история + durable
терминал).
- **Возобновления «с середины» нет** — единственный вход к запуску остаётся `start_pipeline`
через «To Analyse» (D6).
### D5 — Системное терминальное состояние `cancelled` (кросс-каттинг — adr-0026)
Расширить предикат «задача терминальна/завершена» с `{done}` до `{done, cancelled}` там, где он
сейчас захардкожен как `stage != 'done'`, **приводя планировщик в соответствие с уже
существующим терминал-скипом реконсилятора** (стр. 196, `{done, cancelled}`):
- `src/serial_gate.py``repo_has_other_unfinished` (стр. 115/120), claim-фрагмент
`t2.stage != 'done'` (стр. 270), snapshot (стр. 334): `stage != 'done'`
`stage NOT IN ('done','cancelled')`. Иначе отменённая задача навсегда заблокирует репо.
**Маркер ORCH-088** — сверено по `src/serial_gate.py` и
`docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md`: инвариант serial-gate — «не входить в
analysis, пока есть **более ранняя незавершённая** задача»; «незавершённость» определяется
стадией, и расширение терминал-набора `cancelled` лишь harmonизирует определение, не меняя
FIFO-семантику (`t2.id < jobs.task_id`).
- `src/task_deps.py` — dep-gate `t.stage != 'done'` и `is_task_ready`: `NOT IN ('done','cancelled')`.
Иначе отменённая зависимость заблокирует зависимые задачи навсегда. **Маркер ORCH-026**
сверено: зависимость считается «готовой», когда предшественник терминален; `cancelled`
терминальный исход, поэтому его включение в готовность корректно.
- `src/stages.py::STAGE_TRANSITIONS` — добавить терминальный **сток**
`"cancelled": {"next": None, "agent": None, "qg": None}` (параллельно `done`, стр. 21). Это
**не новое ребро** — ни одно exit-гейт ребра не меняется (TRZ §5, NFR-1); сток лишь делает
`get_next_stage('cancelled')` корректным (None).
- `src/reconciler.py::get_active_tasks_for_reconcile` (фильтр `stage != 'done'`) опционально
сузить до `NOT IN ('done','cancelled')` (микро-оптимизация; функционально уже покрыто скипом
стр. 196).
### D6 — Закрытие дыры релонча (FR-6, BR-3/BR-4, OQ-7)
Маршрутизация уже игнорирует промежуточные статусы (Architecture/Development/… → `else`, no-op),
поэтому реальная дыра — relaunch внутри `handle_status_start` при `To Analyse` на существующей
задаче. Решение: **ограничить relaunch единственным легитимным владельцем Needs-Input —
стадией `analysis`** (единственный, кто ставит Needs Input, ORCH-066). Конкретно: ветку
«existing task + idle agent → `enqueue_job(stage_agent,…)`» загейтить условием
`current_stage == 'analysis'`. Для существующей задачи любой иной стадии «To Analyse» → **no-op**
(лог + best-effort Plane-коммент «для перезапуска с нуля: STOP → To Analyse»). Это сохраняет
легитимный «аналитик ответил на вопросы», но устраняет тихий релонч середины пайплайна на старой
ветке (инцидент ORCH-087). Гейт — под `stop_status_enabled` (AC-8: флаг off → поведение 1:1 как
сейчас).
### D7 — Безопасное прерывание merge/deploy (FR-7, BR-6, NFR-3, AC-7)
STOP **никогда** не трогает `main`, не делает force-push, не рестартит/не роняет прод-контейнер и
**не SIGKILL'ит** detached-процесс self-deploy. `cancel_task` классифицирует «критическое окно» по
существующим маркерам (чистая функция `cancel.in_critical_window(task)`, never-raise):
- self-deploy Phase B запущен — sentinel `INITIATED` в `<repos_dir>/.deploy-state-<repo>/<wi>/`
(ORCH-036);
- задача держит merge-lease `<repos_dir>/.merge-lease-<repo>.json` / merge в процессе (ORCH-043/071).
**Вне критического окна** — полный сброс немедленно (D2D4, 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<max → queued`); отдельный
терминальный `cancelled`, нигде не реквью'ящийся, самодокументируем и безопаснее.
- **Удалять строку `tasks` целиком** (OQ-4) — отвергнуто: теряется durable-аудит и durable
терминал в БД; тумбстон ключей (D4) даёт переиспользование с нуля, сохраняя строку и аудит.
- **Архивировать ветку (rename `archive/…`)** вместо удаления (OQ-5) — отвергнуто: лишняя
Gitea-логика; удаление обратимо в Gitea, аналитику хранят docs.
- **Прерывать merge/deploy жёстко (kill detached)** (OQ-6) — отвергнуто: риск half-merge/порчи
`main`/прода (NFR-3); отложенная отмена (D7) безопаснее.
- **Полностью блокировать «To Analyse» на существующей задаче** (D6) — отвергнуто: сломает
легитимный resume аналитика после Needs Input; ограничение релонча стадией `analysis` точечнее.
---
## Последствия
- **+** Оператор получает декларативную кнопку «отменить+сбросить» вместо ручной хирургии;
воспроизводимо, наблюдаемо, обратимо (kill-switch).
- **+** Дыра релонча закрыта; тихий релонч середины пайплайна на старой ветке исключён.
- **+** Терминал-набор планировщика приведён в соответствие с реконсилятором (`{done,cancelled}`)
— устранён латентный рассинхрон ORCH-086.
- **+** Аддитивно/идемпотентно; при `stop_status_enabled=False` — нулевая регрессия; enduro не
затронут.
- **** Вводится **системная терминальная стадия `cancelled`** — затрагивает несколько горячих
предикатов (serial-gate/task-deps/stages). Митигейшн: исчерпывающий список точек в adr-0026 +
тесты на «отменённая задача не клинит очередь / не реквью'ится / переживает рестарт».
- **** Отложенная отмена в критическом окне (D7) — не мгновенная. Митигейшн: прозрачный алерт
«STOP отложен»; необратимый шаг доводится до честного исхода; код в `main` не откатывается (в
объёме BRD — STOP ≠ rollback).
- **** Тумбстон `work_item_id` меняет значение колонки на отменённой строке. Митигейшн: формат
суффикса `#cancelled-<id>` детерминирован и парсится для аудита; `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)