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

@@ -433,6 +433,72 @@ def fetch_issue_state(issue_id: str, project_id: str, timeout: int = 10) -> str
return None
def fetch_blocked_by_issue_ids(issue_id: str, project_id: str, timeout: int = 10) -> list[str]:
"""ORCH-026 (B-1): list the Plane issue UUIDs that ``issue_id`` is BLOCKED-BY.
Reads the Plane issue-relation endpoint and returns the related issue UUIDs
declared as ``blocked_by`` (i.e. the predecessors A that this task B waits
for). Plane's relation payload shape has varied across versions, so the parse
is defensive: it accepts either a grouped object (``{"blocked_by": [...]}``)
or a flat list of ``{"relation_type": ..., "related_issue": ...}`` rows, and
pulls a uuid from ``related_issue`` / ``issue`` / ``id`` (bare uuid or nested
``{"id": ...}``).
never-raise (AC-G1, self-hosting): a Plane outage / non-2xx / unexpected
shape -> ``[]`` (no edge declared), so the ingestion degrades conservatively
and the pipeline never stalls on the network.
"""
if not issue_id or not project_id:
return []
url = (
f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}"
f"/issues/{issue_id}/issue-relation/"
)
try:
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=timeout)
resp.raise_for_status()
body = resp.json()
except Exception as e:
logger.warning(f"fetch_blocked_by_issue_ids failed for {issue_id}: {e}")
return []
def _uuid_of(row) -> str | None:
if isinstance(row, str):
return row
if isinstance(row, dict):
for key in ("related_issue", "issue", "id"):
v = row.get(key)
if isinstance(v, dict):
v = v.get("id")
if v:
return str(v)
return None
out: list[str] = []
try:
rows = []
if isinstance(body, dict):
# Grouped shape: {"blocked_by": [...], "blocking": [...], ...}
if "blocked_by" in body and isinstance(body["blocked_by"], list):
rows = body["blocked_by"]
else:
# Flat shape nested under common envelope keys.
rows = body.get("results") or body.get("relations") or []
elif isinstance(body, list):
rows = body
for row in rows:
# In the flat shape, keep only blocked_by rows.
if isinstance(row, dict) and row.get("relation_type") not in (None, "blocked_by"):
continue
uid = _uuid_of(row)
if uid and uid != issue_id:
out.append(uid)
except Exception as e:
logger.warning(f"fetch_blocked_by_issue_ids parse error for {issue_id}: {e}")
return []
return out
import re as _re