feat(deploy): dedicated "Confirm Deploy" status triggers prod deploy

Split the overloaded `Approved` Plane status: it served BOTH as the human BRD
gate on `analysis` AND as the silent Phase B prod-deploy trigger on `deploy`
(ORCH-036), so a routine approve could launch a self-hosting prod restart.

ORCH-059 introduces a dedicated logical status `confirm_deploy` ("Confirm
Deploy") that triggers ONLY Phase B on `deploy`; `Approved` stays purely a
pipeline gate.

- plane_sync: map "Confirm Deploy" -> "confirm_deploy" in _PLANE_NAME_TO_KEY;
  intentionally absent from _DEFAULT_STATES => fail-closed (no UUID -> .get
  yields None, no KeyError, no blind deploy).
- webhooks/plane: handle_issue_updated routes "Confirm Deploy" (fail-closed
  .get) to new handle_confirm_deploy (guarded to stage=="deploy") ->
  _try_advance_stage(confirm_deploy=True).
- stage_engine: advance_stage gains keyword-only confirm_deploy=False; Phase B
  block returns early for deploy+finished_agent is None but only initiates the
  deploy when confirm_deploy=True; a plain Approved is a deterministic no-op
  (returns before check_deploy_status -> no false БАГ-8 rollback).
- Phase A CTA now asks the operator for "Confirm Deploy", not "Approved".

Contracts unchanged: STAGE_TRANSITIONS, QG_CHECKS, check_deploy_status, hook
exit codes, Phases A/C, merge-gate, DB schema. Conditional like ORCH-35/36
(self-hosting only). Docs updated (CLAUDE.md, architecture/README.md, CHANGELOG).

Refs: ORCH-059

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 19:12:41 +00:00
committed by deployer
parent dd07b58165
commit 86fe8dd509
12 changed files with 813 additions and 35 deletions

View File

@@ -150,8 +150,15 @@ async def handle_issue_updated(data: dict, project_id: str = ""):
# both enduro (b873d9eb) and orchestrator (e331bfb3) In Progress trigger the
# pipeline. Using PLANE_STATES["in_progress"] here was the root-cause blocker.
proj_states = get_project_states(project_id)
# ORCH-059: the dedicated "Confirm Deploy" status is the prod-deploy trigger.
# fail-closed via .get — environments without the status (enduro / API
# fallback) resolve to None, so the branch simply never activates (no KeyError,
# no blind deploy). Checked before `approved` so the two gestures never alias.
confirm_state = proj_states.get("confirm_deploy")
if new_state == proj_states["in_progress"]:
await handle_status_start(data, project_id)
elif confirm_state and new_state == confirm_state:
await handle_confirm_deploy(data, project_id)
elif new_state == proj_states["approved"]:
await handle_verdict(data, project_id, approved=True)
elif new_state == proj_states["rejected"]:
@@ -160,6 +167,45 @@ async def handle_issue_updated(data: dict, project_id: str = ""):
logger.info(f"issue {plane_id} updated to state {new_state[:8]}..., no pipeline action")
async def handle_confirm_deploy(data: dict, project_id: str = ""):
"""ORCH-059: a human flipped the issue to the dedicated "Confirm Deploy"
status — the explicit trigger for the self-hosting prod deploy (Phase B).
Guarded to the `deploy` stage: "Confirm Deploy" is only meaningful on the
approval-pending `deploy` stage (Phase A advanced the task there). On any
other stage it is a no-op-with-log, so a stray Confirm Deploy can never
perturb another gate.
Routes to the unified stage engine with ``confirm_deploy=True`` so ONLY this
path initiates Phase B; a plain Approved on `deploy` stays a no-op (TRZ-3).
"""
plane_id = str(data.get("id") or "")
task = get_task_by_plane_id(plane_id)
if not task:
logger.warning(f"Confirm Deploy for {plane_id} but no task found, ignoring")
return
task_id = task["id"]
current_stage = task["stage"]
repo = task["repo"]
work_item_id = task.get("work_item_id", "")
branch = task.get("branch", "")
if current_stage != "deploy":
logger.info(
f"Confirm Deploy for {plane_id} but stage is '{current_stage}' "
f"(not 'deploy'); no-op"
)
return
logger.info(
f"Task {task_id}: Confirm Deploy status on `deploy` -> initiate Phase B prod deploy"
)
await _try_advance_stage(
task_id, current_stage, repo, work_item_id, branch, confirm_deploy=True
)
async def handle_status_start(data: dict, project_id: str = ""):
"""An issue moved into In Progress.
@@ -633,7 +679,8 @@ async def _rollback_stage(
async def _try_advance_stage(
task_id: int, current_stage: str, repo: str, work_item_id: str, branch: str
task_id: int, current_stage: str, repo: str, work_item_id: str, branch: str,
confirm_deploy: bool = False,
):
"""Thin async wrapper over the unified stage engine (ORCH-4 / M-3).
@@ -642,10 +689,15 @@ async def _try_advance_stage(
is synchronous. We run it off the event loop via asyncio.to_thread so there
is exactly one implementation shared with the launcher.
finished_agent is None on this webhook path (a human Approved status change,
not a finished agent), so the agent-specific rollback branches inside the
engine intentionally do not trigger — the webhook path only runs the QG and
either advances or reports the failure.
finished_agent is None on this webhook path (a human status change, not a
finished agent), so the agent-specific rollback branches inside the engine
intentionally do not trigger — the webhook path only runs the QG and either
advances or reports the failure.
ORCH-059: ``confirm_deploy`` is threaded through (keyword-only on
advance_stage). It is True ONLY on the "Confirm Deploy" path
(handle_confirm_deploy) and gates Phase B of the self-hosting prod deploy; the
plain Approved path (handle_verdict) leaves it at the default False.
"""
import asyncio
from ..stage_engine import advance_stage
@@ -658,6 +710,7 @@ async def _try_advance_stage(
work_item_id,
branch,
None,
confirm_deploy=confirm_deploy,
)