A task carrying the Plane `Bug` label takes a shortened route that skips the `architecture` stage (one opus architect run + ADR + check_architecture_done), replacing heavy analysis with a lite package (bug-report + mandatory regression test plan). EVERY Quality Gate / sub-gate runs UNCHANGED — the route is a scheduler property, not a gate (root invariant NFR-1): STAGE_TRANSITIONS / QG_CHECKS / check_* / machine-verdict keys are byte-for-byte preserved. - src/bug_fast_track.py: new leaf (never-raise) — bug_fast_track_applies (local, network-free, checked first), is_bug_task (labels.has_label, Plane API source), skips_architecture (pure DB-backed routing predicate), snapshot. - src/db.py: additive idempotent tasks.track column (TEXT DEFAULT 'full') + set_task_track / get_task_track helpers (missing/NULL -> 'full', fail-safe). - src/stage_engine.py: routing-override on the analysis-exit edge (track='bug' -> development/developer, skipping architect); brd-review-clock stamp extended to analysis->development. get_next_stage/get_agent_for_stage stay pure. - src/webhooks/plane.py: classify task as bug in start_pipeline (applies-first short-circuit; never-raise -> full cycle on any error). - src/main.py: additive bug_fast_track block in GET /queue + POST /bug-fast-track/escalate (reset 'bug'->'full' to return to the full cycle). - src/config.py: bug_fast_track_enabled / _label / _repos flags (empty CSV -> self-hosting only). - src/notifications.py: optional 🐞 marker on the bug-track card (never-raise). - Prompts: analyst.md (lite bug package + escalation), reviewer.md (regression- test axis) — 52d canon preserved. - Docs: CLAUDE.md, README.md (env + API + section), docs/architecture/README.md, CHANGELOG.md, .env.example. - Tests: tests/test_bug_fast_track*.py + test_db_migrations.py + queue block (TC-01..TC-15). Full regression green (1551 passed). Kill-switch ORCH_BUG_FAST_TRACK_ENABLED=false -> 1:1 pre-ORCH-019 (zero regression; residual track column harmless). Refs: ORCH-019 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
148 lines
6.0 KiB
Python
148 lines
6.0 KiB
Python
"""ORCH-019 — advance_stage routing-override (ADR-001 D3).
|
|
|
|
Covers (04-test-plan.yaml):
|
|
TC-05 bug task: analysis -> development (architecture skipped, developer
|
|
enqueued); non-bug task: analysis -> architecture (architect enqueued).
|
|
TC-06 STAGE_TRANSITIONS is structurally unchanged (set of stages + edges +
|
|
agents + qg byte-for-byte) — the override does NOT mutate the table.
|
|
"""
|
|
import os
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
_test_db = os.path.join(tempfile.gettempdir(), "test_bug_fast_track_routing.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")
|
|
|
|
import src.db as db # noqa: E402
|
|
from src.db import init_db, get_db, set_task_track # noqa: E402
|
|
from src import stage_engine # noqa: E402
|
|
from src import config as cfg # noqa: E402
|
|
from src.stage_engine import advance_stage # noqa: E402
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def fresh_db(monkeypatch, tmp_path):
|
|
dbfile = tmp_path / "r.db"
|
|
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
|
monkeypatch.setattr(cfg.settings, "bug_fast_track_enabled", True, raising=False)
|
|
init_db()
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def silence_side_effects(monkeypatch):
|
|
for name in (
|
|
"notify_stage_change", "notify_qg_failure", "notify_approve_requested",
|
|
"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", "set_issue_analysis",
|
|
"set_issue_awaiting_deploy", "set_issue_deploying", "set_issue_monitoring",
|
|
"set_issue_approved",
|
|
):
|
|
monkeypatch.setattr(stage_engine, name, lambda *a, **k: None, raising=False)
|
|
yield
|
|
|
|
|
|
def _make_task(work_item_id, stage="analysis", repo="orchestrator"):
|
|
conn = get_db()
|
|
cur = conn.execute(
|
|
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) "
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
(work_item_id, work_item_id, repo, f"feature/{work_item_id}", stage, work_item_id),
|
|
)
|
|
tid = cur.lastrowid
|
|
conn.commit()
|
|
conn.close()
|
|
return tid
|
|
|
|
|
|
# --- TC-05 -----------------------------------------------------------------
|
|
def test_tc05_bug_task_skips_architecture():
|
|
tid = _make_task("ORCH-bug", stage="analysis")
|
|
set_task_track(tid, "bug")
|
|
# agent=None -> the webhook Approved-via-status path (gate satisfied, advance).
|
|
res = advance_stage(
|
|
tid, "analysis", "orchestrator", "ORCH-bug", "feature/ORCH-bug",
|
|
finished_agent=None,
|
|
)
|
|
assert res.advanced is True
|
|
assert res.to_stage == "development"
|
|
assert res.enqueued_agent == "developer"
|
|
# DB stage actually advanced past architecture.
|
|
row = db.get_task_by_work_item_id("ORCH-bug")
|
|
assert row["stage"] == "development"
|
|
|
|
|
|
def test_tc05_full_task_keeps_architecture():
|
|
tid = _make_task("ORCH-full", stage="analysis")
|
|
# track defaults to 'full' (no set_task_track call).
|
|
res = advance_stage(
|
|
tid, "analysis", "orchestrator", "ORCH-full", "feature/ORCH-full",
|
|
finished_agent=None,
|
|
)
|
|
assert res.advanced is True
|
|
assert res.to_stage == "architecture"
|
|
assert res.enqueued_agent == "architect"
|
|
|
|
|
|
def test_tc05_killswitch_off_bug_keeps_architecture(monkeypatch):
|
|
monkeypatch.setattr(cfg.settings, "bug_fast_track_enabled", False, raising=False)
|
|
tid = _make_task("ORCH-bugoff", stage="analysis")
|
|
set_task_track(tid, "bug") # stored, but the flag is off -> inert
|
|
res = advance_stage(
|
|
tid, "analysis", "orchestrator", "ORCH-bugoff", "feature/ORCH-bugoff",
|
|
finished_agent=None,
|
|
)
|
|
assert res.to_stage == "architecture"
|
|
assert res.enqueued_agent == "architect"
|
|
|
|
|
|
def test_tc05_bug_only_affects_analysis_edge():
|
|
"""The override is scoped to the analysis-exit edge only — a bug task on
|
|
`development` still routes development -> review (no spurious skips)."""
|
|
tid = _make_task("ORCH-bugdev", stage="development")
|
|
set_task_track(tid, "bug")
|
|
# Make check_ci_green pass deterministically (we only assert routing, not CI).
|
|
import src.stage_engine as se
|
|
orig = se.QG_CHECKS.get("check_ci_green")
|
|
se.QG_CHECKS["check_ci_green"] = lambda *a, **k: (True, "ok")
|
|
try:
|
|
res = advance_stage(
|
|
tid, "development", "orchestrator", "ORCH-bugdev", "feature/ORCH-bugdev",
|
|
finished_agent=None,
|
|
)
|
|
finally:
|
|
if orig is not None:
|
|
se.QG_CHECKS["check_ci_green"] = orig
|
|
assert res.to_stage == "review"
|
|
|
|
|
|
# --- TC-06: STAGE_TRANSITIONS structurally unchanged -----------------------
|
|
def test_tc06_stage_transitions_unchanged():
|
|
from src.stages import STAGE_TRANSITIONS
|
|
expected = {
|
|
"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},
|
|
"cancelled": {"next": None, "agent": None, "qg": None},
|
|
}
|
|
assert STAGE_TRANSITIONS == expected
|
|
|
|
|
|
def test_tc06_get_next_stage_pure():
|
|
"""get_next_stage / get_agent_for_stage stay PURE (no track arg) — the override
|
|
lives in advance_stage, not in stages.py."""
|
|
from src.stages import get_next_stage, get_agent_for_stage
|
|
assert get_next_stage("analysis") == "architecture"
|
|
assert get_agent_for_stage("analysis") == "architect"
|