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:
2026-06-08 19:06:22 +03:00
committed by stream
parent 9019e12d98
commit a74379f657
24 changed files with 1686 additions and 2 deletions

View File

@@ -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,