fix(deploy): clear stale self-deploy markers on rollback; document env

Re-deploy after a FAILED prod deploy wedged the task on `deploy`: the
sentinel markers (approve-requested/initiated/result) are keyed by the
stable work_item_id, so after the БАГ-8 rollback (deploy -> development)
and a developer fix, Phase B's idempotency-guard saw a STALE `initiated`
and became a no-op — the detached hook never re-launched and the
finalizer was never enqueued. Add self_deploy.clear_state (never-raise,
idempotent) and call it on the check_deploy_status FAILED rollback and at
the start of Phase A, so every fresh prod-deploy pass starts clean.

Also document the new ORCH_SELF_DEPLOY_* / ORCH_DEPLOY_* descriptors in
the canonical .env.example (CLAUDE.md rule #8, ТЗ §2.6), modelled on the
ORCH-043 merge-gate block (placeholders only, secrets not committed).

Contracts untouched: STAGE_TRANSITIONS, QG_CHECKS, _parse_deploy_status,
БАГ-8, merge-gate.

Refs: ORCH-036
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 20:42:38 +00:00
committed by Dev Agent
parent 9f43e6a0ae
commit d79defeadd
6 changed files with 138 additions and 0 deletions

View File

@@ -29,6 +29,7 @@ container (reads markers) and the host (writes ``result``):
import logging
import os
import shlex
import shutil
import subprocess
from .config import settings
@@ -160,6 +161,31 @@ def write_marker(repo: str, work_item_id: str | None, name: str, content: str =
return False
def clear_state(repo: str, work_item_id: str | None) -> bool:
"""Remove ALL deploy-state sentinels for this work item (best-effort).
Sentinels are keyed by ``work_item_id`` (stable for the whole task lifetime),
so a FAILED prod-deploy leaves ``approve-requested`` / ``initiated`` / ``result``
behind. Without cleanup, after the БАГ-8 rollback (deploy -> development) and a
fix, the task reaching ``deploy`` again would hit Phase B's idempotency-guard:
the STALE ``initiated`` makes it a no-op, the detached hook never re-launches and
the task wedges on ``deploy`` forever (re-deploy-after-rollback contract broken;
AC-4/AC-10). A stale ``result`` would likewise be mis-read by the new finalizer.
Clearing the whole state dir restores a clean slate for the next pass. Idempotent
(a missing dir is success). Never raises.
"""
d = container_state_dir(repo, work_item_id)
try:
shutil.rmtree(d)
logger.info("clear_state: removed deploy-state dir %s", d)
return True
except FileNotFoundError:
return True
except OSError as e: # noqa: BLE001 - never-raise contract
logger.warning("clear_state error for %s/%s: %s", repo, work_item_id, e)
return False
def read_result(repo: str, work_item_id: str | None) -> tuple[bool, int | None]:
"""Read the ``result`` sentinel (hook exit-code written by the host wrapper).