21 KiB
ADR-001: Осмысленная статусная модель Plane (слой B)
Work Item: ORCH-066 Стадия: architecture Автор: Architect Дата: 2026-06-07 Статус: Accepted
Контракт резолвера, алиасинга и разводки точек простановки статуса. Опирается на BRD (
01-brd.md), ТЗ (02-trz.md), критерии приёмки (03-acceptance-criteria.md). Инфра-предусловие (статусы, создаваемые оператором) —07-infra-requirements.md, риски —10-tech-risks.md.
1. Контекст
Plane-доска оркестратора семантически перегружена: In Progress одновременно
означает «человек запускает конвейер», «идёт анализ», «идёт прод-деплой» и «возврат
из Needs Input». Оператор не различает реальный этап задачи → риск ошибочного ручного
перевода статуса. ORCH-059 уже разгрузил Approved отдельным Confirm Deploy;
ORCH-066 завершает наведение порядка по утверждённой Owner модели.
Жёсткое разделение двух слоёв (инвариант проекта):
| Слой | Что | Источник | ORCH-066 |
|---|---|---|---|
| A | STAGE_TRANSITIONS — машина стадий |
src/stages.py |
НЕ трогаем |
| B | Plane-статусы — индикация на доске | src/plane_sync.py + точки простановки |
меняем только это |
Статус — индикация, не управление. Машинные вердикты по-прежнему читаются только
из YAML-frontmatter артефактов (канон гейтов). Конвейер движут гейты слоя A; смена
Plane-статуса не может продвинуть/откатить задачу (кроме существующих человеческих
триггеров To Analyse/Approved/Rejected, которые и раньше были входами).
Целевая модель Owner:
Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture →
Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying →
Monitoring after Deploy → Done
[...] = действие человека (вход-триггер); остальное ставит орк (индикация).
Ветки: Rejected (откат), Needs Input (только аналитик), Blocked (затык/фейл
деплоя/деградация), Cancelled (человек отменил задачу).
2. Решение
2.1. Реестр логических статусов (src/plane_sync.py)
Вводим 6 новых логических ключей. Имена в _PLANE_NAME_TO_KEY (резолв по имени из
Plane API):
| Логический ключ | Plane name | Назначение |
|---|---|---|
to_analyse |
To Analyse |
Вход-триггер: старт нового конвейера и resume аналитика из Needs Input. |
analysis |
Analysis |
Индикация стадии analysis (орк). |
code_review |
Code-Review |
Индикация стадии review (орк). Заменяет review как видимый статус. |
awaiting_deploy |
Awaiting Deploy |
Phase A approval-pending (орк). |
deploying |
Deploying |
Phase B прод-деплой идёт (орк). |
monitoring |
Monitoring after Deploy |
Phase C / post-deploy окно (орк). |
Существующие ключи сохраняются: backlog, todo, in_progress, needs_input,
in_review, blocked, done, cancelled, architecture, development, review,
testing, approved, rejected. Cancelled уже присутствует.
2.2. Fail-closed резолюция — project-relative alias-fallback (КРИТИЧНО, BR-12)
ТЗ §2.2 предложил статические алиасы на enduro-UUID в _DEFAULT_STATES. Архитектурное
уточнение: для частично сконфигурированного проекта (оператор создал не все новые
статусы) статический enduro-UUID в orchestrator-проекте даст невалидный state → PATCH
422/404. Поэтому деградация делается относительно того же проекта, а не на чужой
UUID.
Два уровня fallback в get_project_states() (success-path), строго в порядке:
-
Резолв по имени из Plane API (как сейчас).
-
Alias-fallback (новый): для каждого отсутствующего нового ключа — UUID его базового ключа из этого же проекта:
_STATE_ALIAS_FALLBACK = { "to_analyse": "in_progress", "analysis": "in_progress", "code_review": "review", "awaiting_deploy": "in_review", "deploying": "in_progress", "monitoring": "done", } # после резолва по имени, ДО _DEFAULT_STATES.setdefault: for new_key, base_key in _STATE_ALIAS_FALLBACK.items(): if new_key not in resolved and resolved.get(base_key): resolved[new_key] = resolved[base_key] -
_DEFAULT_STATES.setdefault(...)(как сейчас) — последний резерв для путей, где API недоступен целиком (if not project_id: return _DEFAULT_STATES, полный провал запроса). В_DEFAULT_STATESновые ключи ТОЖЕ добавляются (= enduro-UUID базового ключа), чтобы любой caller всегда получал полный словарь иstates[key]не кидалKeyError.
Эффект деградации:
| Сценарий | Поведение |
|---|---|
| Orchestrator: все новые статусы созданы | резолв по имени → новые UUID (целевая модель). |
| Orchestrator: создана ЧАСТЬ новых статусов | отсутствующие → собственный базовый UUID проекта → индикация деградирует до текущего статуса, PATCH валиден. |
| Enduro (новые статусы не создаются никогда) | alias-fallback → собственные enduro базовые UUID → строго прежнее поведение (In Progress/Review/Done). |
| Plane API down целиком | _DEFAULT_STATES (enduro-UUID) — без регресса относительно сегодняшнего поведения. |
Это паттерн ORCH-059 AC-7, усиленный project-relative разрешением. Все set_issue_* и
_set_issue_state_direct остаются never-raise (PATCH-исключение логируется, не
пробрасывается) — индикация деградирует, слой A не затрагивается.
2.3. Маппинг стадия → статус
_STAGE_TO_STATE_KEY(живой путьupdate_issue_state→stage_to_state):analysis→analysis(былоin_progress);review→code_review(былоreview).deployостаётсяin_progress(управляется Phase A/B/C напрямую). Остальные — без изменений.STAGE_VISIBILITY_STATE:review→code_review; добавитьanalysis→analysis(для консистентности;set_issue_stage_stateсейчас dormant, но карта обновляется).STAGE_TO_STATE(legacy/test-only) — обновитьanalysis→_DEFAULT_STATES["analysis"],review→_DEFAULT_STATES["code_review"]. UUID-значения байт-в-байт прежние (это алиасы на те же in_progress/review UUID) → тесты на конкретные UUID не краснеют.
2.4. Новые хелперы src/plane_sync.py
Тонкие обёртки по образцу set_issue_in_review (per-project резолв + _set_issue_state_direct,
never-raise):
set_issue_analysis(work_item_id, project_id=None)set_issue_code_review(work_item_id, project_id=None)set_issue_awaiting_deploy(work_item_id, project_id=None)set_issue_deploying(work_item_id, project_id=None)set_issue_monitoring(work_item_id, project_id=None)
get_project_states всегда возвращает полный словарь (см. §2.2), поэтому [key] не
кидает KeyError.
2.5. Точки простановки статуса (разводка)
| Файл:место | Сейчас | Должно стать | AC |
|---|---|---|---|
webhooks/plane.py handle_issue_updated |
new_state == in_progress → handle_status_start |
new_state == to_analyse → handle_status_start (при алиасе совпадает с in_progress) |
AC-1, AC-17 |
webhooks/plane.py start_pipeline (успешный старт) |
статус остаётся In Progress |
в конце старта орк ставит set_issue_analysis |
AC-3 |
webhooks/plane.py handle_status_start (resume-ветка) |
relaunch агента стадии | при relaunch орк ставит set_issue_analysis; fork «старт vs resume» (get_task_by_plane_id + has_active_job_for_task) — без изменений |
AC-2, AC-4 |
webhooks/plane.py _rollback_stage (reject@analysis, ~583) |
set_issue_in_progress |
set_issue_analysis |
AC-3 |
stage_engine.py _handle_analysis_approved_flow (artifacts ready) |
set_issue_in_review |
без изменений (BR-9) | AC-13 |
stage_engine.py _handle_analysis_approved_flow (questions) |
set_issue_needs_input |
без изменений (BR-10) | AC-14 |
stage_engine.py rollback@analysis (architect conflict, ~669) |
set_issue_in_progress |
set_issue_analysis |
AC-3 |
stage_engine.py _handle_self_deploy_phase_a (~1012) |
set_issue_in_review |
set_issue_awaiting_deploy |
AC-6, AC-13 |
stage_engine.py _handle_self_deploy_phase_b (после INITIATED marker) |
статус не меняет | set_issue_deploying |
AC-7 |
stage_engine.py terminal-sync deploy → done (~338) |
set_issue_done для всех |
self (post_deploy_applies): set_issue_monitoring; не-self: set_issue_done как сейчас |
AC-8, AC-9 |
stage_engine.py run_post_deploy_monitor HEALTHY+окно закрыто (~1260) |
статус не трогает | set_issue_done (явно) |
AC-10 |
stage_engine.py run_post_deploy_monitor DEGRADED (~1273) |
alert/log | set_issue_blocked (+ существующий ALERT_ONLY) |
AC-11 |
Разводка terminal-sync (детально, AC-8/AC-9). Текущий код безусловно зовёт
set_issue_done на next_stage == "done", затем (для self) армит post-deploy monitor.
Разводим по post_deploy.post_deploy_applies(repo):
if next_stage == "done" and work_item_id:
if post_deploy.post_deploy_applies(repo):
set_issue_monitoring(work_item_id) # self: окно наблюдения, НЕ Done сразу
else:
set_issue_done(work_item_id) # не-self: терминальный Done как сейчас
# арм монитора (существующий блок ~361) — без изменений
Финальный Done/Blocked для self-hosting перекладывается на run_post_deploy_monitor.
При деградированном алиасе monitoring==done self-hosting показывает Done и затем
монитор держит Done/флипает Blocked — поведение идентично сегодняшнему.
AC-12 (инвариант ORCH-021): добавление set_issue_blocked в DEGRADED-ветку —
только индикация; тик по-прежнему НИКОГДА не рестартит/откатывает прод-контейнер
(self-hosting остаётся ALERT_ONLY). set_issue_blocked — Plane-PATCH, не действие над
контейнером.
Cancelled (AC-15): изменений кода НЕ требует. handle_issue_updated реагирует только
на to_analyse/approved/rejected; Cancelled падает в else → «no pipeline action».
Орк не делает advance/rollback — индикация, не управление. Критерий выполнен существующим
кодом.
2.6. Reconciler (src/reconciler.py)
-
F-2
_reconcile_plane_project: заменить триггерin_progress→to_analyseв списке запрашиваемых статусов (list_issues_by_state([to_analyse, approved, rejected])) и в_reconcile_plane_issueмаршрутизироватьnew_state == to_analyse→handle_status_start. При алиасеto_analyse == in_progress(enduro) поведение идентично текущему (один UUID;list_issues_by_stateдедуплицирует черезset). AC-19. -
Guard 2
_is_blocked_or_needs_input: расширить skip-множество активными ожиданиямиawaiting_deploy/deploying/monitoring(BR-13, AC-20). Анти-регресс enduro (КРИТИЧНО): новые ключи алиасятся наin_review/in_progress/done; добавить их в skip «как есть» → на enduroIn Progress/Done-задачи начнут ошибочно пропускаться F-1 (регресс ORCH-053/060). Поэтому активные ожидания включаются в skip только когда они РАЗЛИЧНЫ от базовых рабочих статусов проекта (т.е. реально созданы):base_working = {states.get(k) for k in ( "backlog","todo","in_progress","in_review","review", "architecture","development","testing","approved","rejected","done")} extra_waits = {states.get("awaiting_deploy"), states.get("deploying"), states.get("monitoring")} - base_working - {None} skip_set = {states.get("blocked"), states.get("needs_input")} | extra_waits return cur in skip_setEnduro (алиасы схлопываются в base) →
extra_waits == {}→ нулевой регресс. Orchestrator (отдельные UUID) → три реальных статуса в skip → BR-13. Семантику метода обобщаем до «human-or-active-wait»; флагreconcile_skip_blocked_enabledпродолжает гасить этот networked-чек. F-1 и так структурно не оживляет эти состояния (Phase A:check_deploy_statusred → silent; Deploying: active finalizer job → active-job guard; Monitoring: стадияdone→ не итерируется) — Guard 2 это defense-in-depth по требованию Owner.
2.7. Без kill-switch
Отдельный env-флаг новой модели не вводится. Раскат естественно гейтится инфра-предусловием: пока оператор не создал новые статусы — alias-fallback (§2.2) держит строго прежнее поведение; создал — резолв по имени включает новую модель. Это проще отдельного флага и соответствует принципу «минимум зависимостей». (ТЗ §6 допускает флаг как опциональный — сознательно отказываемся.)
3. Затронутые модули (карта изменений)
| Модуль | Изменение |
|---|---|
src/plane_sync.py |
_PLANE_NAME_TO_KEY +6; _DEFAULT_STATES +6 (enduro-alias UUID); _STATE_ALIAS_FALLBACK (новое) + применение в get_project_states; _STAGE_TO_STATE_KEY (analysis/review); STAGE_VISIBILITY_STATE; STAGE_TO_STATE (legacy); 5 новых set_issue_*. |
src/webhooks/plane.py |
триггер in_progress→to_analyse в handle_issue_updated; set_issue_analysis в start_pipeline и resume-ветке handle_status_start; _rollback_stage reject@analysis → set_issue_analysis. |
src/stage_engine.py |
Phase A → set_issue_awaiting_deploy; Phase B → set_issue_deploying; terminal-sync split (monitoring vs done); post-deploy monitor HEALTHY→set_issue_done, DEGRADED→set_issue_blocked; rollback@analysis (architect conflict) set_issue_in_progress→set_issue_analysis. |
src/reconciler.py |
F-2 триггер to_analyse; Guard 2 skip-set + анти-регресс subtraction. |
src/stages.py |
НЕ трогаем (инвариант слоя A). |
src/config.py |
Без изменений (kill-switch не вводится). |
4. Инварианты (проверяемые, AC-21/AC-22)
src/stages.pySTAGE_TRANSITIONS— diff пуст (байт-в-байт).QG_CHECKS,check_deploy_status/_parse_deploy_status, exit-коды хука (0/1/2), merge-gate,check_branch_mergeable/check_staging_image_fresh, схема БД — без изменений.Confirm Deploy(ORCH-059), механизмNeeds Input(analyst-only) — без изменений.- Новых HTTP-эндпоинтов нет;
GET /queue/GET /statusконтракт без изменений. - Миграций БД нет (
tasksне хранит Plane-статус; источник истины — стадия в БД + Plane API). - Все новые
set_issue_*/ резолв — never-raise. - Не-self (enduro) терминальный
deploy → Done— без регресса.
5. Последствия
Плюсы
- Доска читаема: каждый этап = осмысленный статус; человеческие входы визуально отделены от индикации.
In Progressразгружен: больше не «всё подряд».- Fail-closed усилен (project-relative): частичная конфигурация не ломает ни индикацию, ни конвейер.
- Слой A нетронут → нулевой риск для машины стадий и гейтов всех проектов (self-hosting).
- Нет нового флага/таблицы → меньше движущихся частей.
Минусы / ограничения
- Требуется ручное инфра-действие оператора (создать 6 статусов в проекте ORCH) — до
этого orchestrator деградирует до старой индикации (см.
07-infra-requirements.md). - Статусы кэшируются per-process (
_STATES_CACHE): после создания статусов нуженreload_project_states()или рестарт staging (не прод — см. self-hosting риск). - Guard-2 subtraction добавляет немного логики; покрывается тестами (enduro-алиас → пустой extra; orchestrator → три статуса).
Self-hosting (⚠️): изменения — слой B (Plane-индикация) + reconciler-гварды; машина
стадий и контракты деплоя нетронуты. Выкладка ОБЯЗАТЕЛЬНО через deploy-staging (8501)
до прод-деплоя орка. Прод-контейнер не рестартить в рамках задачи вне штатного staging-гейта.
6. Альтернативы (отклонены)
- Статический enduro-UUID алиас (ТЗ §2.2 буквально): ломается на частичной конфигурации orchestrator-проекта (чужой UUID → PATCH 422). Заменён project-relative alias-fallback (§2.2).
- Глобальный env kill-switch новой модели: избыточен — инфра-предусловие уже даёт естественный гейт раската (§2.7).
- Хранить Plane-статус в
tasks(миграция БД): не нужно; источник истины — стадия + живой Plane API. Нарушило бы инвариант «без лишних зависимостей». - Менять
STAGE_TRANSITIONSради новых статусов: запрещено (инвариант слоя A); статусы — индикация, отделены от машины стадий.