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>
167 lines
7.7 KiB
Python
167 lines
7.7 KiB
Python
"""ORCH-019: bug-fast-track — a cheaper/shorter pipeline route for bug-fix tasks.
|
|
|
|
Leaf module — pure, unit-testable logic over the config flags + the proven Plane
|
|
label apparatus (``labels.has_label`` -> ``plane_sync``, ORCH-089). Mirrors the
|
|
leaf pattern of ``src/labels.py`` / ``src/serial_gate.py``: imports only
|
|
``config`` (and lazily ``labels`` / ``db`` / ``qg.checks``), never
|
|
``stage_engine`` / ``launcher``.
|
|
|
|
What it decides (ADR-001):
|
|
* Whether the bug-fast-track is in scope for a repo (``bug_fast_track_applies``)
|
|
— a LOCAL, network-free check evaluated FIRST.
|
|
* Whether a given Plane issue carries the ``Bug`` label (``is_bug_task``) — the
|
|
only network call, made ONLY after ``applies()`` is True, so a disabled
|
|
kill-switch costs zero network and yields zero regression (AC-6).
|
|
* Whether a task's stored track skips the ``architecture`` stage
|
|
(``skips_architecture``) — a pure predicate over the DB-stored ``track``,
|
|
read in the hot ``advance_stage`` path WITHOUT any network call (NFR-4).
|
|
|
|
never-raise contract (BR-6/AC-6, fail-safe to the FULL cycle): every public
|
|
function degrades to "full cycle" on ANY error / ambiguity / Plane
|
|
unavailability / disabled flag. There is NO fail-open here — the conservative
|
|
default is always the full pipeline (with ``architecture``), so an error can
|
|
never silently skip a stage.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from .config import settings
|
|
|
|
logger = logging.getLogger("orchestrator.bug_fast_track")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scope / kill-switch (mirrors _auto_label_applies / serial_gate_applies)
|
|
# ---------------------------------------------------------------------------
|
|
def bug_fast_track_applies(repo: str) -> bool:
|
|
"""Whether the bug-fast-track is REAL for ``repo`` (ADR-001 D6 / AC-6).
|
|
|
|
* ``bug_fast_track_enabled=False`` -> always False (kill-switch; start and
|
|
routing are 1:1 as before ORCH-019, and — crucially — ``has_label`` is
|
|
never consulted, so no new network call on start, AC-6).
|
|
* ``bug_fast_track_repos`` (CSV) non-empty -> real only for the listed repos.
|
|
* empty CSV -> self-hosting only (``orchestrator``) — the safe default (the
|
|
track is first burnt in on the orchestrator itself, where the `Bug` label
|
|
is guaranteed to exist; enduro opts in via an explicit CSV entry).
|
|
Checked FIRST (local, network-free); never raises -> False on error (degrade
|
|
to "full cycle", which matches the kill-switch-off behaviour).
|
|
"""
|
|
try:
|
|
if not getattr(settings, "bug_fast_track_enabled", False):
|
|
return False
|
|
raw = (getattr(settings, "bug_fast_track_repos", "") or "").strip()
|
|
if raw:
|
|
allowed = {r.strip().lower() for r in raw.split(",") if r.strip()}
|
|
return (repo or "").strip().lower() in allowed
|
|
# Lazy import keeps this module a leaf (avoids importing qg at load).
|
|
from .qg.checks import is_self_hosting_repo
|
|
return is_self_hosting_repo(repo)
|
|
except Exception as e: # noqa: BLE001 - never-raise -> full cycle
|
|
logger.warning("bug_fast_track_applies error for %s: %s", repo, e)
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Classification (the ONLY network call; ADR-001 D1)
|
|
# ---------------------------------------------------------------------------
|
|
def is_bug_task(work_item_id: str, project_id: str | None = None) -> bool:
|
|
"""True iff the issue carries the configured ``Bug`` label (Plane API source).
|
|
|
|
``bug_fast_track_applies`` is assumed already True (checked by the caller —
|
|
the gate idiom ``applies(repo) and is_bug_task(...)`` short-circuits before any
|
|
network call when the kill-switch is off). Delegates to the proven
|
|
``labels.has_label`` (fetch_issue_labels + get_project_labels, normalization,
|
|
TTL-cache, source-of-truth = Plane API, not the webhook payload).
|
|
|
|
Any error / ambiguity / Plane unavailability -> **False** (fail-safe -> full
|
|
cycle, never silently fast-track on doubt).
|
|
"""
|
|
try:
|
|
label = (getattr(settings, "bug_fast_track_label", "") or "").strip()
|
|
if not label:
|
|
return False
|
|
from . import labels
|
|
return bool(labels.has_label(work_item_id, label, project_id))
|
|
except Exception as e: # noqa: BLE001 - never-raise -> full cycle
|
|
logger.warning(
|
|
"is_bug_task error for %s -> fail-safe (full cycle): %s", work_item_id, e
|
|
)
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Routing predicate (pure, DB-backed; hot path — NO network, NFR-4) — ADR-001 D3
|
|
# ---------------------------------------------------------------------------
|
|
def skips_architecture(track: str | None) -> bool:
|
|
"""Whether a task with stored ``track`` skips the ``architecture`` stage.
|
|
|
|
Pure predicate (no I/O): True iff the kill-switch is on AND ``track == 'bug'``.
|
|
Used by ``advance_stage`` on the analysis-exit edge to map
|
|
``analysis -> architecture`` to ``analysis -> development`` for a bug task.
|
|
A disabled flag -> always False (1:1 prior routing); any error -> False
|
|
(fail-safe -> full cycle).
|
|
"""
|
|
try:
|
|
if not getattr(settings, "bug_fast_track_enabled", False):
|
|
return False
|
|
return (track or "").strip().lower() == "bug"
|
|
except Exception as e: # noqa: BLE001 - never-raise -> full cycle
|
|
logger.warning("skips_architecture error for track=%r: %s", track, e)
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Observability snapshot for GET /queue (ADR-001 D7)
|
|
# ---------------------------------------------------------------------------
|
|
def snapshot() -> dict:
|
|
"""Read-only bug-fast-track summary for GET /queue (additive block). never-raise.
|
|
|
|
Surfaces the flags + a savings metric derived from the existing telemetry: the
|
|
count of tasks on the bug track and the number of ``architecture`` agent runs
|
|
those tasks structurally skipped (one per bug task = ``est_saved_architecture_runs``).
|
|
Any error -> a minimal dict with the flags (never crashes the endpoint).
|
|
"""
|
|
try:
|
|
enabled = bool(getattr(settings, "bug_fast_track_enabled", False))
|
|
except Exception: # noqa: BLE001
|
|
enabled = False
|
|
try:
|
|
label = getattr(settings, "bug_fast_track_label", "Bug") or "Bug"
|
|
except Exception: # noqa: BLE001
|
|
label = "Bug"
|
|
try:
|
|
repos_cfg = getattr(settings, "bug_fast_track_repos", "") or ""
|
|
except Exception: # noqa: BLE001
|
|
repos_cfg = ""
|
|
active_bug_tasks = 0
|
|
total_bug_tasks = 0
|
|
try:
|
|
from . import db
|
|
conn = db.get_db()
|
|
try:
|
|
# ORCH-090 terminal set {done,cancelled}: "active" = not terminal.
|
|
row = conn.execute(
|
|
"SELECT "
|
|
" COUNT(*) AS total, "
|
|
" SUM(CASE WHEN stage NOT IN ('done','cancelled') THEN 1 ELSE 0 END) AS active "
|
|
"FROM tasks WHERE track = 'bug'"
|
|
).fetchone()
|
|
if row:
|
|
total_bug_tasks = int(row["total"] or 0)
|
|
active_bug_tasks = int(row["active"] or 0)
|
|
finally:
|
|
conn.close()
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("bug_fast_track snapshot count error: %s", e)
|
|
return {
|
|
"enabled": enabled,
|
|
"label": label,
|
|
"repos": repos_cfg,
|
|
"active_bug_tasks": active_bug_tasks,
|
|
"total_bug_tasks": total_bug_tasks,
|
|
# Each bug task skips exactly one `architecture` stage (one architect agent
|
|
# run + ADR). This is the structural savings the track buys (FR-7 / AC-7).
|
|
"est_saved_architecture_runs": total_bug_tasks,
|
|
}
|