feat(plane): осмысленная статусная модель Plane (слой B — индикация)

Приводит статусы доски Plane к смыслу стадий конвейера, сохраняя
инвариант «статус — индикация, а не управление». Меняется только слой B
(отображение: src/plane_sync.py + точки выставления статуса в
stage_engine.py/webhooks/plane.py/reconciler.py); слой A — машина стадий
src/stages.py::STAGE_TRANSITIONS — остаётся байт-в-байт неизменным (AC-21).

- 6 новых логических ключей статуса (to_analyse, analysis, code_review,
  awaiting_deploy, deploying, monitoring) + сеттеры и диспетчер
  set_issue_stage_state.
- Project-relative alias-fallback (BR-12): новый ключ деградирует на
  базовый UUID того же проекта → нулевая регрессия для enduro-trails.
- Самодеплой (ORCH-036) индицирует фазы: Awaiting Deploy / Deploying;
  terminal-sync для self-hosting → Monitoring after Deploy, для прочих →
  терминальный Done.
- Post-deploy монитор (ORCH-021): HEALTHY → Done, DEGRADED → Blocked
  (только индикация; self-hosting ALERT_ONLY, прод не трогается, BR-5).
- Reconciler: триггер старта/резюма на To Analyse; Guard 2 учитывает
  новые активные ожидания без расширения skip-set на алиасах.
- never-raise контракт сеттеров и резолвера состояний сохранён.
- Раскатка — созданием статусов в Plane оператором, без kill-switch.

Инварианты не менялись: STAGE_TRANSITIONS, QG_CHECKS (12 чеков),
check_deploy_status, exit-код-контракт хука, merge-gate, схема БД.

ADR: docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md
Тесты: test_plane_status_model, test_plane_to_analyse_resume,
test_plane_status_failclosed + TC в существующих наборах. 774 passed.

Refs: ORCH-066

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 21:54:12 +00:00
committed by deployer
parent 22d3b77426
commit 0dfddf93f0
19 changed files with 999 additions and 40 deletions

View File

@@ -193,12 +193,22 @@ class Reconciler:
self._note_unblock(task.get("work_item_id") or str(task_id), stage)
def _is_blocked_or_needs_input(self, task: dict) -> bool:
"""ORCH-060 Guard 2: is this issue in an explicit human Plane gate?
"""Guard 2 (ORCH-060 + ORCH-066): is this issue waiting for a human OR in
an active orchestrator wait that F-1 must not "revive"?
Variant A (no schema migration): resolve the task's Plane project, fetch
the issue's current state uuid and compare against the project's
``blocked`` / ``needs_input`` states. ``tasks`` has no status column, so
the live Plane state is the source of truth.
the issue's current state uuid and compare against a skip-set. ``tasks``
has no status column, so the live Plane state is the source of truth.
Skip-set = explicit human gates (``blocked`` / ``needs_input``) PLUS the
ORCH-066 active waits (``awaiting_deploy`` / ``deploying`` / ``monitoring``,
BR-13). **Anti-regress (CRITICAL):** the active-wait keys alias onto
``in_review`` / ``in_progress`` / ``done`` on a project that did not create
them. Adding them verbatim would make F-1 wrongly skip enduro
In Progress / Done tasks (regression of ORCH-053/060). So they are
included ONLY when DISTINCT from the project's base working statuses
(i.e. actually created as separate statuses): enduro collapses them to {}
-> zero regress; orchestrator keeps three real statuses -> BR-13.
**Never-raise, conservative fallback.** Any error / unresolved project /
missing state -> return ``True`` (treat as "possibly blocked" -> skip):
@@ -219,7 +229,22 @@ class Reconciler:
cur = fetch_issue_state(issue_id, pid)
if cur is None:
return True # Plane unreachable / no state -> conservative skip
return cur in {states.get("blocked"), states.get("needs_input")}
# ORCH-066 BR-13: active orchestrator waits, minus base working
# statuses so aliased (enduro) keys never widen the skip-set.
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_set
except Exception as e: # noqa: BLE001 - never break the tick
logger.warning(
f"reconciler Guard 2: blocked-check failed for task "
@@ -241,15 +266,19 @@ class Reconciler:
def _reconcile_plane_project(self, proj) -> None:
pid = proj.plane_project_id
# Resolve the actionable state uuids per-project (never hardcode).
# ORCH-066 (AC-19): the start/resume trigger is `To Analyse` (was
# In Progress). On a project without that status, `to_analyse` aliases to
# the project's own `in_progress` UUID, so enduro behaviour is identical
# (and `list_issues_by_state` deduplicates the uuid via its internal set).
states = get_project_states(pid)
in_progress = states["in_progress"]
to_analyse = states["to_analyse"]
approved = states["approved"]
rejected = states["rejected"]
issues = list_issues_by_state(pid, [in_progress, approved, rejected])
issues = list_issues_by_state(pid, [to_analyse, approved, rejected])
for issue in issues:
try:
self._reconcile_plane_issue(
issue, pid, in_progress, approved, rejected
issue, pid, to_analyse, approved, rejected
)
except Exception as e: # noqa: BLE001 - isolate one issue's failure
logger.error(
@@ -258,7 +287,7 @@ class Reconciler:
def _reconcile_plane_issue(
self, issue: dict, project_id: str,
in_progress: str, approved: str, rejected: str,
to_analyse: str, approved: str, rejected: str,
) -> None:
issue_id = str(issue.get("id") or "")
if not issue_id:
@@ -288,10 +317,16 @@ class Reconciler:
"description_stripped": issue.get("description_stripped", ""),
}
if new_state == in_progress and task is None:
# In Progress without a task -> start the pipeline (lost start webhook).
if new_state == to_analyse and task is None:
# To Analyse without a task -> start the pipeline (lost start webhook).
self._dispatch(handle_status_start, issue_data, project_id)
self._note_unblock(issue_id, "analysis")
elif new_state == to_analyse and task is not None:
# To Analyse with an existing (idle) task -> resume the analyst from
# Needs Input (lost resume webhook). handle_status_start applies its
# own busy-guard / start-vs-resume fork.
self._dispatch(handle_status_start, issue_data, project_id)
self._note_unblock(task.get("work_item_id") or issue_id, task["stage"])
elif new_state == approved and task is not None:
# Approved but the stage never advanced -> replay the verdict.
self._dispatch(handle_verdict, issue_data, project_id, approved=True)