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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user