feat(ORCH-026): task dependencies (B waits for A) + single-repo merge serialization
Level A — merge/deploy serialization within one repo: reuse the existing ORCH-043/065 merge-lease (no new mechanism); the only new logic is an unconditional pre-merge rebase in check_branch_mergeable — under the held lease, auto_rebase_onto_main is ALWAYS called when premerge_rebase_always (default True), not just when the branch is behind. No-op on an up-to-date branch (rebase keeps HEAD, force-with-lease -> "Everything up-to-date", CI not triggered). Kill-switch off -> ORCH-043 behaviour 1:1. Level B — declarative task dependencies: additive job_deps table (CREATE ... IF NOT EXISTS, no live-DB migration); claim_next_job gate (NOT EXISTS) defers a job whose depends-on tasks are not yet 'done' without occupying a max_concurrency slot; inert on empty job_deps -> zero regression. New leaf src/task_deps.py (never-raise): is_task_ready (fail-open), DFS cycle detection + Blocked/alert, declare/ingest_plane_relations (db source never hits the network on the hot path), snapshot. Telegram waiting-line, /queue observability, reconciler skip + cycle backstop, reaper untouched. Invariants unchanged: STAGE_TRANSITIONS, QG_CHECKS registry (dep gate is a claim_next_job врезка, not a registered QG), DB schema of existing tables, HTTP endpoints; non-self repos remain a no-op on empty deps/scope. Flags: ORCH_PREMERGE_REBASE_ALWAYS, ORCH_TASK_DEPS_ENABLED, ORCH_TASK_DEPS_SOURCE. Docs: docs/architecture/README.md, CLAUDE.md, .env.example, CHANGELOG.md, adr-0015. Tests: tests/test_orch026_*.py (64 tests); full suite 991 green. Refs: ORCH-026 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,7 @@ from .plane_sync import (
|
||||
from .webhooks.plane import handle_status_start, handle_verdict
|
||||
from .notifications import send_telegram, link_for
|
||||
from . import projects
|
||||
from . import task_deps
|
||||
|
||||
logger = logging.getLogger("orchestrator.reconciler")
|
||||
|
||||
@@ -165,6 +166,16 @@ class Reconciler:
|
||||
f"reconciler F-1: task {task.get('id')} "
|
||||
f"(stage={task.get('stage')}) failed: {e}"
|
||||
)
|
||||
# ORCH-026 (B-3) backstop: surface ANY dependency deadlock in the declared
|
||||
# graph, even one whose tasks are not individually evaluated above (e.g. no
|
||||
# active queued job). One alert per cycle; never-raise.
|
||||
if settings.task_deps_enabled:
|
||||
try:
|
||||
cyc = task_deps.find_any_cycle()
|
||||
if cyc:
|
||||
task_deps.handle_cycle(cyc)
|
||||
except Exception as e: # noqa: BLE001 - never break the sweep
|
||||
logger.error(f"reconciler F-1: cycle backstop failed: {e}")
|
||||
|
||||
def _reconcile_gate_task(self, task: dict) -> None:
|
||||
task_id = task["id"]
|
||||
@@ -194,6 +205,18 @@ class Reconciler:
|
||||
# Networked; runs after Guard 1 so escalated tasks never hit Plane.
|
||||
if self._is_blocked_or_needs_input(task):
|
||||
return
|
||||
# ORCH-026 Guard 3 (B-5): a task blocked by an unfinished declared
|
||||
# dependency is legitimately waiting, NOT stuck -> F-1 must not advance it
|
||||
# past its depends-on (mirrors the Blocked/Needs-Input skip). Local DB,
|
||||
# never-raise (is_task_ready fails OPEN). If the wait is actually a
|
||||
# dependency DEADLOCK (cycle), surface it (Blocked + alert) once.
|
||||
if settings.task_deps_enabled:
|
||||
ready, _waiting = task_deps.is_task_ready(task_id)
|
||||
if not ready:
|
||||
cyc = task_deps.detect_cycle(task_id)
|
||||
if cyc:
|
||||
task_deps.handle_cycle(cyc)
|
||||
return
|
||||
result = advance_if_gate_passed(
|
||||
task_id,
|
||||
stage,
|
||||
|
||||
Reference in New Issue
Block a user