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

@@ -48,6 +48,9 @@ def silence_side_effects(monkeypatch):
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
# ORCH-066 status setters.
"set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying",
"set_issue_monitoring",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
@@ -127,6 +130,9 @@ def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch):
assert _jobs() == []
# The restart-safe approve-requested marker was written.
assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.APPROVE_REQUESTED)
# ORCH-066 AC-6/AC-13: Phase A indicates `Awaiting Deploy`, NOT `In Review`.
stage_engine.set_issue_awaiting_deploy.assert_called_once_with("ORCH-036")
stage_engine.set_issue_in_review.assert_not_called()
# ---------------------------------------------------------------------------
@@ -151,6 +157,8 @@ def test_tc06_approved_calls_prod_hook_exactly_once(monkeypatch):
# The finalizer was enqueued.
assert any(j["agent"] == "deploy-finalizer" for j in _jobs())
assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.INITIATED)
# ORCH-066 AC-7: Phase B indicates `Deploying` on a successful initiate.
stage_engine.set_issue_deploying.assert_called_once_with("ORCH-036")
# 2nd (duplicate) Approved -> idempotent no-op, hook NOT called again.
res2 = advance_stage(

View File

@@ -45,6 +45,9 @@ def silence_side_effects(monkeypatch):
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
# ORCH-066 status setters.
"set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying",
"set_issue_monitoring",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
@@ -106,3 +109,56 @@ def test_tc17_success_deploy_syncs_terminal_done(monkeypatch):
release.assert_called_once_with("orchestrator", "feature/ORCH-036-x")
# No agent is launched leaving deploy (terminal).
assert _jobs() == []
# ---------------------------------------------------------------------------
# ORCH-066 TC-08 (AC-8): self-hosting deploy->done -> Monitoring after Deploy,
# NOT terminal Done. The post-deploy monitor finalises.
# ---------------------------------------------------------------------------
def test_tc08_self_deploy_done_sets_monitoring_not_done(monkeypatch):
self_deploy.write_marker("orchestrator", "ORCH-036", self_deploy.RESULT, "0")
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": _pass},
)
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
# post_deploy applies for the self-hosting repo with the monitor enabled.
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", True)
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_repos", "")
# arm_monitor is orthogonal; stub it so this test stays on the status contract.
monkeypatch.setattr(stage_engine.post_deploy, "arm_monitor", MagicMock(return_value=True))
task_id = _make_task("deploy")
stage_engine.run_deploy_finalizer(
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"}
)
assert _stage(task_id) == "done"
# Self-hosting: the issue enters the Monitoring window, NOT terminal Done yet.
stage_engine.set_issue_monitoring.assert_called_once_with("ORCH-036")
stage_engine.set_issue_done.assert_not_called()
# ---------------------------------------------------------------------------
# ORCH-066 TC-09 (AC-9): non-self repo deploy->done -> terminal Done (no regress).
# ---------------------------------------------------------------------------
def test_tc09_non_self_deploy_done_sets_done(monkeypatch):
self_deploy.write_marker("enduro-trails", "ET-042", self_deploy.RESULT, "0")
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": _pass},
)
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
# Monitor enabled, but the empty CSV means it applies ONLY to the self repo;
# a non-self repo therefore takes the unchanged terminal-Done path.
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", True)
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_repos", "")
task_id = _make_task("deploy", repo="enduro-trails", branch="feature/ET-042-x", wi="ET-042")
stage_engine.run_deploy_finalizer(
{"task_id": task_id, "repo": "enduro-trails", "id": 1, "agent": "deploy-finalizer"}
)
assert _stage(task_id) == "done"
stage_engine.set_issue_done.assert_called_once_with("ET-042")
stage_engine.set_issue_monitoring.assert_not_called()

View File

@@ -40,11 +40,15 @@ ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
_PROJECT_STATES = {
ENDURO_PLANE_ID: {
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
# ORCH-066: To Analyse is the start trigger; with the status absent it
# aliases to in_progress (the real get_project_states fallback).
"to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967",
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
},
ORCH_PLANE_ID: {
"in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
"to_analyse": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
"approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff",
"rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3",
},

View File

@@ -460,3 +460,59 @@ def test_default_states_et_values():
assert ps._DEFAULT_STATES[key] == expected, (
f"_DEFAULT_STATES['{key}']: expected {expected}, got {ps._DEFAULT_STATES.get(key)}"
)
# ---------------------------------------------------------------------------
# ORCH-066 TC-19 (AC-18): resolve-by-name — when a project DEFINES one of the
# new statuses, get_project_states must use its OWN UUID, not the default alias.
# ---------------------------------------------------------------------------
def test_orch066_tc19_name_resolution_beats_alias():
"""A project that created 'Analysis' / 'Code-Review' / 'Awaiting Deploy' /
'Deploying' / 'Monitoring after Deploy' resolves each to its own project
UUID (via _PLANE_NAME_TO_KEY), NOT the aliased base-key UUID."""
import src.plane_sync as ps
new_uuids = {
"Analysis": "11111111-0000-0000-0000-000000000001",
"Code-Review": "11111111-0000-0000-0000-000000000002",
"Awaiting Deploy": "11111111-0000-0000-0000-000000000003",
"Deploying": "11111111-0000-0000-0000-000000000004",
"Monitoring after Deploy": "11111111-0000-0000-0000-000000000005",
"To Analyse": "11111111-0000-0000-0000-000000000006",
}
# Start from the full ORCH base set, then add the dedicated new statuses.
results = _make_states_response(ORCH_STATES)["results"]
results += [{"id": uid, "name": name} for name, uid in new_uuids.items()]
with patch("src.plane_sync.httpx.get") as mock_get:
mock_get.return_value = _fake_response({"results": results})
states = ps.get_project_states(ORCH_PROJECT_ID)
# Each new key resolved to the project's OWN UUID, not the base-key alias.
assert states["analysis"] == new_uuids["Analysis"]
assert states["code_review"] == new_uuids["Code-Review"]
assert states["awaiting_deploy"] == new_uuids["Awaiting Deploy"]
assert states["deploying"] == new_uuids["Deploying"]
assert states["monitoring"] == new_uuids["Monitoring after Deploy"]
assert states["to_analyse"] == new_uuids["To Analyse"]
# Sanity: they are NOT the aliased base UUIDs.
assert states["analysis"] != states["in_progress"]
assert states["code_review"] != states["review"]
assert states["awaiting_deploy"] != states["in_review"]
def test_orch066_tc19_missing_new_status_aliases_to_project_base():
"""BR-12: a project WITHOUT the new statuses degrades each new key to its OWN
base UUID (not a foreign enduro UUID) — keeping the PATCH state valid."""
import src.plane_sync as ps
with patch("src.plane_sync.httpx.get") as mock_get:
mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES))
states = ps.get_project_states(ORCH_PROJECT_ID)
# No dedicated new statuses -> alias to THIS project's base UUIDs.
assert states["analysis"] == ORCH_STATES["in_progress"]
assert states["to_analyse"] == ORCH_STATES["in_progress"]
assert states["code_review"] == ORCH_STATES["review"]
assert states["awaiting_deploy"] == ORCH_STATES["in_review"]
assert states["deploying"] == ORCH_STATES["in_progress"]
assert states["monitoring"] == ORCH_STATES["done"]

View File

@@ -0,0 +1,131 @@
"""ORCH-066 fail-closed (CRITICAL) — the new status model must never wedge the
pipeline when the 6 Plane statuses are absent or Plane is unreachable.
* TC-16 (AC-16, BR-12) — a project WITHOUT the new statuses resolves each new
logical key to its OWN base UUID (to_analyse=in_progress, code_review=review,
awaiting_deploy=in_review, monitoring=done); no exception.
* TC-17 (AC-16) — Plane API down -> get_project_states falls back to
_DEFAULT_STATES; every set_issue_* helper is never-raise.
* TC-18 (AC-17) — enduro In Progress STILL starts the pipeline through
the to_analyse alias (= in_progress UUID).
httpx is mocked; no network.
"""
import os
os.environ.setdefault("ORCH_PLANE_API_URL", "http://plane.local")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_WORKSPACE_SLUG", "test-ws")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
from unittest.mock import patch, MagicMock, AsyncMock # noqa: E402
import pytest # noqa: E402
from src import plane_sync as PS # noqa: E402
ENDURO_PROJECT_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
# An enduro-style states response: the 6 ORCH-066 statuses are NOT created.
_ENDURO_BASE = {
"Backlog": "backlog-u", "Todo": "todo-u", "In Progress": "ip-u",
"Review": "review-u", "In Review": "inrev-u", "Approved": "appr-u",
"Rejected": "rej-u", "Done": "done-u", "Needs Input": "ni-u",
"Blocked": "blk-u",
}
def _states_response(name_to_uuid):
return {"results": [{"id": uid, "name": name} for name, uid in name_to_uuid.items()]}
def _fake_resp(data, status=200):
m = MagicMock()
m.status_code = status
m.json.return_value = data
m.raise_for_status.return_value = None
return m
@pytest.fixture(autouse=True)
def _reset_cache():
PS.reload_project_states()
yield
PS.reload_project_states()
# ---------------------------------------------------------------------------
# TC-16 (AC-16 / BR-12): partial project -> alias to its own base UUIDs, no raise.
# ---------------------------------------------------------------------------
def test_tc16_partial_project_aliases_to_base_uuids():
with patch("src.plane_sync.httpx.get") as mock_get:
mock_get.return_value = _fake_resp(_states_response(_ENDURO_BASE))
states = PS.get_project_states(ENDURO_PROJECT_ID)
# The new keys degrade to THIS project's base UUIDs (not foreign defaults).
assert states["to_analyse"] == states["in_progress"] == "ip-u"
assert states["analysis"] == "ip-u"
assert states["code_review"] == states["review"] == "review-u"
assert states["awaiting_deploy"] == states["in_review"] == "inrev-u"
assert states["deploying"] == "ip-u"
assert states["monitoring"] == states["done"] == "done-u"
# ---------------------------------------------------------------------------
# TC-17 (AC-16): Plane API down -> _DEFAULT_STATES; set_issue_* never-raise.
# ---------------------------------------------------------------------------
def test_tc17_api_down_falls_back_to_defaults():
with patch("src.plane_sync.httpx.get", side_effect=Exception("plane down")):
states = PS.get_project_states(ENDURO_PROJECT_ID)
assert states is PS._DEFAULT_STATES
# All new keys exist in the defaults (so callers never KeyError).
for k in ("to_analyse", "analysis", "code_review", "awaiting_deploy",
"deploying", "monitoring"):
assert k in states
def test_tc17_set_issue_helpers_never_raise_when_issue_missing():
# find_issue_id returns None (issue not in Plane) -> helpers log + return,
# they must NOT raise. Covers every ORCH-066 setter.
setters = [
PS.set_issue_analysis, PS.set_issue_code_review,
PS.set_issue_awaiting_deploy, PS.set_issue_deploying,
PS.set_issue_monitoring,
]
with patch("src.plane_sync._resolve_project_id", return_value="proj-1"), \
patch("src.plane_sync.get_project_states", return_value=PS._DEFAULT_STATES), \
patch("src.plane_sync.find_issue_id", return_value=None), \
patch("src.plane_sync.httpx.patch") as mock_patch:
for setter in setters:
setter("ET-1") # must not raise
# No PATCH issued because the issue could not be resolved.
mock_patch.assert_not_called()
def test_tc17_set_issue_helpers_never_raise_when_patch_errors():
# The PATCH itself blows up -> _set_issue_state_direct swallows it.
with patch("src.plane_sync._resolve_project_id", return_value="proj-1"), \
patch("src.plane_sync.get_project_states", return_value=PS._DEFAULT_STATES), \
patch("src.plane_sync.find_issue_id", return_value="issue-uuid"), \
patch("src.plane_sync.httpx.patch", side_effect=Exception("boom")):
PS.set_issue_monitoring("ET-1") # must not raise
# ---------------------------------------------------------------------------
# TC-18 (AC-17): enduro In Progress still starts the pipeline via to_analyse alias.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tc18_enduro_in_progress_still_starts_via_alias():
from src.webhooks.plane import handle_issue_updated
with patch("src.plane_sync.httpx.get") as mock_get, \
patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \
patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict:
mock_get.return_value = _fake_resp(_states_response(_ENDURO_BASE))
# enduro never created 'To Analyse' -> to_analyse aliases In Progress (ip-u).
data = {"id": "et-issue", "state": {"id": "ip-u", "name": "In Progress"}}
await handle_issue_updated(data, ENDURO_PROJECT_ID)
mock_start.assert_called_once()
mock_verdict.assert_not_called()

View File

@@ -0,0 +1,151 @@
"""ORCH-066: the meaningful Plane status model (layer B) — unit coverage.
These tests pin the layer-B behaviour WITHOUT touching layer A (the stage
machine). httpx is mocked; no network.
* TC-03 (AC-3) — the analyst start/resume indicates `Analysis`, not In Progress.
* TC-05 (AC-5) — entering the `review` stage indicates `Code-Review`.
* TC-14 (AC-14) — set_issue_needs_input is unchanged (still PATCHes Needs Input).
* TC-22 (AC-21) — STAGE_TRANSITIONS (layer A) is byte-identical (explicit pin).
* TC-23 (AC-22) — QG_CHECKS registry + check_deploy_status contract unchanged.
"""
import os
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
from unittest.mock import patch, MagicMock # noqa: E402
from src import plane_sync as PS # noqa: E402
# A per-project state map that DEFINES the new ORCH-066 statuses with distinct
# UUIDs, so we can prove the dedicated status (not the base alias) is used.
_STATES_WITH_NEW = {
"in_progress": "ip-uuid",
"review": "review-uuid",
"in_review": "inrev-uuid",
"needs_input": "ni-uuid",
"done": "done-uuid",
"analysis": "analysis-uuid",
"code_review": "codereview-uuid",
"awaiting_deploy": "awaiting-uuid",
"deploying": "deploying-uuid",
"monitoring": "monitoring-uuid",
}
def _patch_resolve(states):
"""Patch find_issue_id + _resolve_project_id + get_project_states so a
set_issue_* helper reaches the PATCH with a known per-project state map."""
return (
patch("src.plane_sync.httpx.patch"),
patch("src.plane_sync.find_issue_id", return_value="issue-uuid"),
patch("src.plane_sync._resolve_project_id", return_value="proj-1"),
patch("src.plane_sync.get_project_states", return_value=states),
)
def _run_setter(setter, states):
p_patch, p_find, p_res, p_states = _patch_resolve(states)
with p_patch as mock_patch, p_find, p_res, p_states:
resp = MagicMock()
resp.raise_for_status.return_value = None
mock_patch.return_value = resp
setter("ET-1")
return mock_patch
# ---------------------------------------------------------------------------
# TC-03 (AC-3): analyst start/resume indicates Analysis.
# ---------------------------------------------------------------------------
def test_tc03_set_issue_analysis_patches_analysis_uuid():
mock_patch = _run_setter(PS.set_issue_analysis, _STATES_WITH_NEW)
# The dedicated Analysis UUID is used (NOT the in_progress base alias).
assert mock_patch.call_args.kwargs["json"]["state"] == "analysis-uuid"
assert mock_patch.call_args.kwargs["json"]["state"] != _STATES_WITH_NEW["in_progress"]
def test_tc03_analysis_aliases_in_progress_when_absent():
# A project without the Analysis status -> get_project_states already aliased
# 'analysis' onto its in_progress UUID, so the PATCH degrades gracefully.
aliased = dict(_STATES_WITH_NEW)
aliased["analysis"] = aliased["in_progress"]
mock_patch = _run_setter(PS.set_issue_analysis, aliased)
assert mock_patch.call_args.kwargs["json"]["state"] == aliased["in_progress"]
# ---------------------------------------------------------------------------
# TC-05 (AC-5): the review stage indicates Code-Review.
# ---------------------------------------------------------------------------
def test_tc05_review_stage_maps_to_code_review():
# Both the stage->state-key map and the stage-visibility map point review at
# the new code_review logical key (layer B only).
assert PS._STAGE_TO_STATE_KEY["review"] == "code_review"
assert PS.STAGE_VISIBILITY_STATE["review"] == "code_review"
def test_tc05_set_issue_stage_state_review_patches_code_review_uuid():
p_patch, p_find, p_res, p_states = _patch_resolve(_STATES_WITH_NEW)
with p_patch as mock_patch, p_find, p_res, p_states:
resp = MagicMock()
resp.raise_for_status.return_value = None
mock_patch.return_value = resp
PS.set_issue_stage_state("ET-1", "review")
assert mock_patch.call_args.kwargs["json"]["state"] == "codereview-uuid"
def test_tc05_set_issue_code_review_helper_patches_code_review_uuid():
mock_patch = _run_setter(PS.set_issue_code_review, _STATES_WITH_NEW)
assert mock_patch.call_args.kwargs["json"]["state"] == "codereview-uuid"
# ---------------------------------------------------------------------------
# TC-14 (AC-14): Needs Input behaviour unchanged.
# ---------------------------------------------------------------------------
def test_tc14_needs_input_unchanged():
mock_patch = _run_setter(PS.set_issue_needs_input, _STATES_WITH_NEW)
assert mock_patch.call_args.kwargs["json"]["state"] == "ni-uuid"
# ---------------------------------------------------------------------------
# TC-22 (AC-21): STAGE_TRANSITIONS (layer A) is byte-identical. ORCH-066 changes
# ONLY layer B — the machine must not move.
# ---------------------------------------------------------------------------
def test_tc22_stage_transitions_unchanged():
from src.stages import STAGE_TRANSITIONS
assert STAGE_TRANSITIONS == {
"created": {"next": "analysis", "agent": "analyst", "qg": None},
"analysis": {"next": "architecture", "agent": "architect", "qg": "check_analysis_approved"},
"architecture": {"next": "development", "agent": "developer", "qg": "check_architecture_done"},
"development": {"next": "review", "agent": "reviewer", "qg": "check_ci_green"},
"review": {"next": "testing", "agent": "tester", "qg": "check_reviewer_verdict"},
"testing": {"next": "deploy-staging", "agent": "deployer", "qg": "check_tests_passed"},
"deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"},
"deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"},
"done": {"next": None, "agent": None, "qg": None},
}
# ---------------------------------------------------------------------------
# TC-23 (AC-22): QG_CHECKS registry + check_deploy_status contract unchanged.
# ---------------------------------------------------------------------------
def test_tc23_qg_checks_registry_unchanged():
from src.qg.checks import QG_CHECKS
assert set(QG_CHECKS.keys()) == {
"check_analysis_approved", "check_analysis_complete", "check_architecture_done",
"check_ci_green", "check_review_approved", "check_tests_passed",
"check_reviewer_verdict", "check_tests_local", "check_deploy_status",
"check_staging_status", "check_branch_mergeable", "check_staging_image_fresh",
}
def test_tc23_check_deploy_status_signature_unchanged():
import inspect
from src.qg.checks import check_deploy_status, QG_CHECKS
# Registry still points at the same callable.
assert QG_CHECKS["check_deploy_status"] is check_deploy_status
# (repo, work_item_id, branch=None) -> tuple[bool, str] contract intact.
params = list(inspect.signature(check_deploy_status).parameters)
assert params == ["repo", "work_item_id", "branch"]

View File

@@ -0,0 +1,114 @@
"""ORCH-066: To Analyse resume semantics (F-1 status-only model).
`handle_status_start` forks on (existing task?) + (active job?):
* TC-02 (AC-2, BR-11) — an EXISTING task with NO active job + To Analyse ->
RELAUNCH the current stage's agent (the analyst resumes from Needs Input);
NO second task is created; the issue is re-indicated `Analysis`.
* TC-04 (AC-4) — an EXISTING task WITH an active job + To Analyse ->
busy-guard: NO relaunch (no double launch).
handle_status_start is exercised directly; enqueue_job + Plane side-effects are
mocked. A real isolated sqlite DB backs get_task_by_plane_id / the job guard.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch066_to_analyse_resume.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import patch, AsyncMock, MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src.webhooks.plane import handle_status_start # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
yield
if os.path.exists(_test_db):
os.unlink(_test_db)
def _make_task(plane_id="resume-1", stage="analysis", repo="enduro-trails",
branch="feature/ET-001-x", wi="ET-001"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(plane_id, wi, repo, branch, stage),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def _count(plane_id):
conn = get_db()
n = conn.execute("SELECT COUNT(*) FROM tasks WHERE plane_id=?", (plane_id,)).fetchone()[0]
conn.close()
return n
# ---------------------------------------------------------------------------
# TC-02 (AC-2 / BR-11): existing task, no active job -> RELAUNCH (resume), no dup.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tc02_to_analyse_resume_relaunches_analyst_no_duplicate():
_make_task("resume-1", stage="analysis")
data = {"id": "resume-1", "state": {"id": "ip-uuid", "name": "To Analyse"}}
with patch("src.webhooks.plane.enqueue_job", return_value=7) as mock_enqueue, \
patch("src.webhooks.plane.start_pipeline", new_callable=AsyncMock) as mock_start, \
patch("src.plane_sync.add_comment", MagicMock()), \
patch("src.plane_sync.set_issue_analysis") as mock_analysis:
await handle_status_start(data, "proj-1")
# No new pipeline start (it is a resume, not a fresh task).
mock_start.assert_not_called()
assert _count("resume-1") == 1 # NO duplicate task
# The current stage's agent (analyst) was relaunched exactly once.
assert mock_enqueue.call_count == 1
assert mock_enqueue.call_args.args[0] == "analyst"
# AC-3: the resumed analysis stage is re-indicated as Analysis.
mock_analysis.assert_called_once_with("ET-001")
# ---------------------------------------------------------------------------
# TC-04 (AC-4): existing task WITH active job -> busy-guard, NO relaunch.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tc04_to_analyse_with_active_job_does_not_relaunch():
tid = _make_task("resume-2", stage="analysis")
# Seed an active (queued) job so has_active_job_for_task reports busy.
conn = get_db()
conn.execute(
"INSERT INTO jobs (agent, repo, task_id, status) VALUES (?, ?, ?, 'queued')",
("analyst", "enduro-trails", tid),
)
conn.commit()
conn.close()
data = {"id": "resume-2", "state": {"id": "ip-uuid", "name": "To Analyse"}}
with patch("src.webhooks.plane.enqueue_job", return_value=9) as mock_enqueue, \
patch("src.webhooks.plane.start_pipeline", new_callable=AsyncMock) as mock_start, \
patch("src.plane_sync.add_comment", MagicMock()), \
patch("src.plane_sync.set_issue_analysis") as mock_analysis:
await handle_status_start(data, "proj-1")
mock_start.assert_not_called()
mock_enqueue.assert_not_called() # busy-guard held: NO double launch
mock_analysis.assert_not_called()
assert _count("resume-2") == 1

View File

@@ -47,13 +47,18 @@ UNKNOWN_PLANE_ID = "deadbeef-0000-0000-0000-000000000000"
_PROJECT_STATES = {
ENDURO_PLANE_ID: {
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
# ORCH-066: To Analyse is the start trigger; absent -> aliases in_progress.
"to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967",
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
"cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17",
},
ORCH_PLANE_ID: {
"in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
"to_analyse": "e331bfb3-e17e-4699-ba48-4abb89c21b7b",
"approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff",
"rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3",
"cancelled": "59d1d210-8e3a-4a83-930a-cbc5dbf6ad85",
},
}
@@ -219,3 +224,38 @@ def test_prefixes_independent_per_project(mock_branch, mock_docs, mock_launcher)
assert rows["o1"] == "ORCH-001"
assert rows["o2"] == "ORCH-002"
assert rows["e1"] == "ET-001"
# ---------------------------------------------------------------------------
# ORCH-066 TC-15 (AC-15): Cancelled is a valid human exit — the orchestrator
# performs NO advance/rollback (indication, not control).
# ---------------------------------------------------------------------------
@patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock)
@patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock)
@patch("src.webhooks.plane.launcher")
def test_cancelled_state_does_no_pipeline_action(mock_launcher, mock_start, mock_verdict):
cancelled = _PROJECT_STATES[ORCH_PLANE_ID]["cancelled"]
resp = client.post(
"/webhook/plane",
json={
"event": "issue",
"action": "updated",
"data": {
"id": "cancel-1",
"name": "A cancelled work item",
"description_stripped": "This is a sufficiently long description.",
"project": ORCH_PLANE_ID,
"state": {"id": cancelled, "name": "Cancelled", "group": "cancelled"},
},
},
)
assert resp.status_code == 200
# Neither the start nor the verdict (advance/rollback) handler ran.
mock_start.assert_not_called()
mock_verdict.assert_not_called()
mock_launcher.launch.assert_not_called()
# No task created off a Cancelled transition.
conn = get_db()
task = conn.execute("SELECT * FROM tasks WHERE plane_id='cancel-1'").fetchone()
conn.close()
assert task is None

View File

@@ -47,6 +47,9 @@ def silence_side_effects(monkeypatch):
"send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
# ORCH-066 status setters.
"set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying",
"set_issue_monitoring",
):
monkeypatch.setattr(stage_engine, name, MagicMock())
@@ -242,6 +245,81 @@ def test_finished_window_tick_is_noop(monkeypatch):
probe.assert_not_called()
# ---------------------------------------------------------------------------
# ORCH-066 TC-10 (AC-10): HEALTHY + window exhausted -> Plane state Done.
# ---------------------------------------------------------------------------
def test_orch066_tc10_clean_window_close_sets_done(monkeypatch):
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30)
monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) # budget=1
monkeypatch.setattr(
post_deploy, "probe_signals",
lambda url: post_deploy.ProbeResult(True, 2, 0, "ok"),
)
task_id = _make_task("done")
post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed")
stage_engine.run_post_deploy_monitor(
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"}
)
# Clean window close -> terminal Done indicated on Plane; window marked done.
stage_engine.set_issue_done.assert_called_once_with("ORCH-021")
stage_engine.set_issue_blocked.assert_not_called()
assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE)
# No follow-up tick once the window closed.
assert _jobs("post-deploy-monitor") == []
# ---------------------------------------------------------------------------
# ORCH-066 TC-11 (AC-11): DEGRADED -> Plane state Blocked (self-hosting alert).
# ---------------------------------------------------------------------------
def test_orch066_tc11_degraded_sets_blocked(monkeypatch):
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 1)
monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30)
monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30)
monkeypatch.setattr(
post_deploy, "probe_signals",
lambda url: post_deploy.ProbeResult(False, 2, 2, "down"),
)
monkeypatch.setattr(stage_engine, "_notify_post_deploy", MagicMock())
task_id = _make_task("done")
post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed")
stage_engine.run_post_deploy_monitor(
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"}
)
# DEGRADED -> Blocked indication (NOT Done); window finalised.
stage_engine.set_issue_blocked.assert_called_once_with("ORCH-021")
stage_engine.set_issue_done.assert_not_called()
assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE)
# ---------------------------------------------------------------------------
# ORCH-066 TC-12 (AC-12): a self-hosting tick NEVER restarts/rolls back prod —
# the Blocked indication is the ONLY mutation (ORCH-021 BR-5 preserved).
# ---------------------------------------------------------------------------
def test_orch066_tc12_self_tick_never_restarts_prod(monkeypatch):
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", True)
monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 1)
monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30)
monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30)
monkeypatch.setattr(
post_deploy, "probe_signals",
lambda url: post_deploy.ProbeResult(False, 2, 2, "down"),
)
monkeypatch.setattr(stage_engine, "_notify_post_deploy", MagicMock())
# The rollback hook (the only restart-capable path) MUST stay untouched for self.
rollback = MagicMock(return_value=(0, "ok"))
monkeypatch.setattr(post_deploy, "run_rollback", rollback)
task_id = _make_task("done")
post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed")
stage_engine.run_post_deploy_monitor(
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"}
)
rollback.assert_not_called() # never restarts/rolls back the prod self-container
stage_engine.set_issue_blocked.assert_called_once_with("ORCH-021") # indication only
# ---------------------------------------------------------------------------
# TC-20 — /queue observability block
# ---------------------------------------------------------------------------

View File

@@ -572,7 +572,7 @@ def test_tc060_08_no_gate_call_on_escalated(monkeypatch):
# ---------------------------------------------------------------------------
def test_tc060_09_f2_does_not_replay_blocked(monkeypatch):
states = {
"in_progress": "IP", "approved": "AP", "rejected": "RJ",
"in_progress": "IP", "to_analyse": "IP", "approved": "AP", "rejected": "RJ",
"blocked": "BL", "needs_input": "NI",
}
monkeypatch.setattr(
@@ -680,3 +680,67 @@ def test_tc060_subflag_disables_only_guard2(monkeypatch):
assert _stage_of(blocked) == "review" # Guard 2 muted
assert _stage_of(escalated) == "development" # Guard 1 still skips
# ---------------------------------------------------------------------------
# ORCH-066 TC-21 (AC-20 / BR-13): Guard 2 skips the active orchestrator waits
# (Awaiting Deploy / Deploying / Monitoring after Deploy) ONLY when they are
# DISTINCT statuses — an aliased (enduro) project must NOT widen the skip-set.
# ---------------------------------------------------------------------------
def _guard2(monkeypatch, states, cur_state):
"""Drive _is_blocked_or_needs_input with a chosen project state map + the
issue's current Plane state uuid."""
monkeypatch.setattr(reconciler_mod, "get_project_states",
MagicMock(return_value=states))
monkeypatch.setattr(reconciler_mod, "fetch_issue_state",
MagicMock(return_value=cur_state))
monkeypatch.setattr(
reconciler_mod.projects, "get_project_by_repo",
MagicMock(return_value=MagicMock(plane_project_id="proj-test")),
)
monkeypatch.setattr(
reconciler_mod.settings, "reconcile_skip_blocked_enabled", True
)
task = {"id": 1, "repo": "orchestrator", "plane_id": "iss-1"}
return Reconciler()._is_blocked_or_needs_input(task)
# orchestrator has the three new statuses as DISTINCT UUIDs.
_DISTINCT_STATES = {
"backlog": "bl-u", "todo": "td-u", "in_progress": "ip-u", "in_review": "inrev-u",
"review": "rev-u", "architecture": "arch-u", "development": "dev-u",
"testing": "test-u", "approved": "appr-u", "rejected": "rej-u", "done": "done-u",
"blocked": "blocked-u", "needs_input": "ni-u",
"awaiting_deploy": "await-u", "deploying": "deploying-u", "monitoring": "monitor-u",
}
def test_tc21_guard2_skips_distinct_active_waits(monkeypatch):
# Each active-wait status (distinct UUID) -> skipped (not revived).
assert _guard2(monkeypatch, _DISTINCT_STATES, "await-u") is True
assert _guard2(monkeypatch, _DISTINCT_STATES, "deploying-u") is True
assert _guard2(monkeypatch, _DISTINCT_STATES, "monitor-u") is True
# Explicit human gates still skip.
assert _guard2(monkeypatch, _DISTINCT_STATES, "blocked-u") is True
assert _guard2(monkeypatch, _DISTINCT_STATES, "ni-u") is True
# A normal working state is NOT skipped (gets reconciled).
assert _guard2(monkeypatch, _DISTINCT_STATES, "ip-u") is False
def test_tc21_guard2_aliased_waits_do_not_widen_skipset(monkeypatch):
# enduro: the new keys alias onto base working statuses -> they must NOT make
# F-1 skip a genuinely In Progress / In Review / Done task (anti-regress).
aliased = {
"backlog": "bl-u", "todo": "td-u", "in_progress": "ip-u", "in_review": "inrev-u",
"review": "rev-u", "architecture": "arch-u", "development": "dev-u",
"testing": "test-u", "approved": "appr-u", "rejected": "rej-u", "done": "done-u",
"blocked": "blocked-u", "needs_input": "ni-u",
# aliased onto base UUIDs (project did not create dedicated statuses).
"awaiting_deploy": "inrev-u", "deploying": "ip-u", "monitoring": "done-u",
}
# In Progress / In Review / Done are base working states -> NOT skipped.
assert _guard2(monkeypatch, aliased, "ip-u") is False
assert _guard2(monkeypatch, aliased, "inrev-u") is False
assert _guard2(monkeypatch, aliased, "done-u") is False
# The explicit human gates still skip.
assert _guard2(monkeypatch, aliased, "blocked-u") is True

View File

@@ -59,6 +59,9 @@ def single_project(monkeypatch):
reconciler_mod, "get_project_states",
lambda pid: {
"in_progress": _IN_PROGRESS,
# ORCH-066: To Analyse is the F-2 start/resume trigger; absent in this
# project -> aliases in_progress (real get_project_states fallback).
"to_analyse": _IN_PROGRESS,
"approved": _APPROVED,
"rejected": _REJECTED,
},
@@ -114,6 +117,46 @@ def test_tc11_in_progress_without_task_starts_pipeline(monkeypatch, single_proje
verdict.assert_not_called()
# ---------------------------------------------------------------------------
# ORCH-066 TC-20 (AC-19): F-2 polls the DISTINCT To Analyse status and routes it
# to handle_status_start (a lost start/resume webhook is recovered).
# ---------------------------------------------------------------------------
def test_tc20_distinct_to_analyse_polled_and_routed(monkeypatch):
_TO_ANALYSE = "uuid-to-analyse" # distinct from in_progress
monkeypatch.setattr(
reconciler_mod, "get_project_states",
lambda pid: {
"in_progress": _IN_PROGRESS,
"to_analyse": _TO_ANALYSE, # dedicated status created
"approved": _APPROVED,
"rejected": _REJECTED,
},
)
monkeypatch.setattr(
reconciler_mod.projects, "PROJECTS",
[SimpleNamespace(plane_project_id="proj-1", repo="enduro-trails",
work_item_prefix="ET")],
)
start, verdict = _patch_handlers(monkeypatch)
polled = {}
def fake_list(pid, states):
polled["states"] = list(states)
return [{"id": "iss-ta", "state": {"id": _TO_ANALYSE}, "updated_at": _OLD_TS,
"name": "Lost start"}]
monkeypatch.setattr(reconciler_mod, "list_issues_by_state", fake_list)
Reconciler().reconcile_plane_once()
# The To Analyse UUID is in the polled set and routed to start (not verdict).
assert _TO_ANALYSE in polled["states"]
assert start.call_count == 1
assert start.call_args.args[0]["id"] == "iss-ta"
verdict.assert_not_called()
# ---------------------------------------------------------------------------
# TC-12: Approved with an existing task, no active job -> handle_verdict(True).
# ---------------------------------------------------------------------------
@@ -279,7 +322,10 @@ def test_tc17_polls_all_projects_resolves_states_per_project(monkeypatch):
def fake_states(pid):
states_calls.append(pid)
return {"in_progress": _IN_PROGRESS, "approved": _APPROVED, "rejected": _REJECTED}
return {
"in_progress": _IN_PROGRESS, "to_analyse": _IN_PROGRESS,
"approved": _APPROVED, "rejected": _REJECTED,
}
def fake_issues(pid, states):
issues_calls.append((pid, tuple(states)))

View File

@@ -68,10 +68,18 @@ def test_set_issue_stage_state_patches_correct_uuid(mock_proj, mock_find, mock_p
@patch("src.plane_sync.httpx.patch")
@patch("src.plane_sync.find_issue_id", return_value="issue-uuid")
@patch("src.plane_sync._resolve_project_id", return_value="proj-1")
def test_set_issue_stage_state_noop_for_analysis(mock_proj, mock_find, mock_patch):
# analysis has no dedicated board status -> no PATCH at all.
def test_set_issue_stage_state_noop_for_deploy(mock_proj, mock_find, mock_patch):
# ORCH-066: analysis now HAS a dedicated status (Analysis) -> it PATCHes.
# deploy still has no board status here (driven by Phase A/B/C) -> no-op.
resp = MagicMock()
resp.raise_for_status.return_value = None
mock_patch.return_value = resp
PS.set_issue_stage_state("ET-1", "analysis")
mock_patch.assert_not_called()
# analysis aliases in_progress when the Analysis status is absent.
assert mock_patch.call_args.kwargs["json"]["state"] == PS.PLANE_STATES["analysis"]
mock_patch.reset_mock()
PS.set_issue_stage_state("ET-1", "deploy")
mock_patch.assert_not_called()