26 KiB
work_item, stage, author_agent, status, created_at, model_used
| work_item | stage | author_agent | status | created_at | model_used |
|---|---|---|---|---|---|
| ORCH-090 | architecture | architect | proposed | 2026-06-09 | 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содержит UUIDcancelled(стр. 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 → graceagent_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<max → queued, иначеfailed, стр. 315–334). - Терминал-скип уже учитывает
cancelled:reconciler._is_terminal_state(groupcompleted/cancelledили логический ключcancelled, стр. 398–415) и 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, стр. 98–107); функции удаления Gitea-ветки нет.- Запуск с нуля —
handle_status_start → start_pipeline(ветка + docs + analyst, стр. 430–626);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 UPDATESET 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-dupcreate_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-gatet.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).
Вне критического окна — полный сброс немедленно (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)(GiteaDELETE /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(envORCH_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, leafcancel.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)