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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user