"""Controller-owned work allocator policy (#600). Builds on the #613 control-plane DB substrate (``ControlPlaneDB.assign_and_lease``). Workers must not self-select exclusive work under the standard multi-LLM workflow. They call ``gitea_allocate_next_work`` which: 1. Inspects candidate Gitea issues/PRs (never raw monitoring incidents). 2. Applies ADR routing policy (role, terminal path, leases, blocked, deps). 3. Atomically assigns + leases the selected item in one DB transaction. This module is pure selection + substrate orchestration. Gitea I/O for live inventory lives in the MCP tool wrapper so tests can inject candidates. #612 remains downstream: bridge-created Gitea issues become candidates only after they exist as normal issues; this module never assigns incidents. """ from __future__ import annotations import os import uuid from dataclasses import dataclass, field from typing import Any, Sequence from control_plane_db import ( ControlPlaneDB, ControlPlaneError, InvalidWorkKindError, LeaseRequiredError, WORK_KINDS, ) # Outcomes required by #600 / ADR §5. OUTCOME_ASSIGNED = "assigned_work" OUTCOME_WAIT = "wait" OUTCOME_BLOCKED_TERMINAL = "blocked_by_terminal_path" OUTCOME_BLOCKED_LEASE = "blocked_by_active_lease" OUTCOME_NEEDS_CONTROLLER = "needs_controller" OUTCOME_NO_SAFE = "no_safe_work" OUTCOME_ROLE_INELIGIBLE = "role_ineligible" OUTCOME_PREVIEW = "preview" # dry-run only (apply=false) ROLE_AUTHOR = "author" ROLE_REVIEWER = "reviewer" ROLE_MERGER = "merger" ROLE_RECONCILER = "reconciler" ROLE_CONTROLLER = "controller" VALID_ROLES = frozenset( {ROLE_AUTHOR, ROLE_REVIEWER, ROLE_MERGER, ROLE_RECONCILER, ROLE_CONTROLLER} ) # Default action matrices by role (mutation gate will re-check). ROLE_ACTIONS: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = { ROLE_AUTHOR: ( ("implement", "comment", "push", "create_pr"), ("approve", "merge", "request_changes", "self_select_without_assignment"), ), ROLE_REVIEWER: ( ("review", "comment", "approve", "request_changes"), ("merge", "push", "create_pr", "self_select_without_assignment"), ), ROLE_MERGER: ( ("merge", "comment"), ("approve", "request_changes", "push", "create_pr", "self_select_without_assignment"), ), ROLE_RECONCILER: ( ("comment", "diagnose", "cleanup"), ("approve", "merge", "push", "create_pr", "self_select_without_assignment"), ), ROLE_CONTROLLER: ( ("comment", "diagnose", "allocate"), ("approve", "merge", "push", "create_pr"), ), } @dataclass class WorkCandidate: """One assignable Gitea issue or PR presented to the allocator.""" kind: str # issue | pr number: int state: str = "open" labels: tuple[str, ...] = () title: str = "" priority: int = 0 head_sha: str | None = None # Routing signals (callers derive from Gitea / review feedback). request_changes_current_head: bool = False approval_on_current_head: bool = False approval_stale: bool = False approval_contaminated: bool = False mergeable: bool = False blocked: bool = False dependency_unmet: bool = False dependency_reason: str | None = None already_claimed_elsewhere: bool = False def __post_init__(self) -> None: self.kind = (self.kind or "").strip().lower() self.state = (self.state or "open").strip().lower() self.labels = tuple( str(x).strip().lower() for x in (self.labels or ()) if str(x).strip() ) if self.kind not in WORK_KINDS: raise InvalidWorkKindError( f"candidate kind '{self.kind}' is not assignable; only " f"{sorted(WORK_KINDS)} (never raw incidents)" ) def as_dict(self) -> dict[str, Any]: return { "kind": self.kind, "number": self.number, "state": self.state, "labels": list(self.labels), "title": self.title, "priority": self.priority, "head_sha": self.head_sha, "request_changes_current_head": self.request_changes_current_head, "approval_on_current_head": self.approval_on_current_head, "approval_stale": self.approval_stale, "approval_contaminated": self.approval_contaminated, "mergeable": self.mergeable, "blocked": self.blocked, "dependency_unmet": self.dependency_unmet, "dependency_reason": self.dependency_reason, } @dataclass class SkipRecord: kind: str number: int reason: str def as_dict(self) -> dict[str, Any]: return {"kind": self.kind, "number": self.number, "reason": self.reason} def normalize_role(role: str | None, *, profile_name: str | None = None) -> str: """Map profile/role strings to a canonical allocator role.""" raw = (role or "").strip().lower() if raw in VALID_ROLES: return raw prof = (profile_name or "").strip().lower() for token in VALID_ROLES: if token in prof or prof.endswith(f"-{token}"): return token if "author" in raw: return ROLE_AUTHOR if "review" in raw: return ROLE_REVIEWER if "merg" in raw: return ROLE_MERGER if "reconcil" in raw: return ROLE_RECONCILER if "control" in raw: return ROLE_CONTROLLER raise ControlPlaneError( f"unknown allocator role '{role}' (profile={profile_name!r}); " f"expected one of {sorted(VALID_ROLES)}" ) def expected_role_for_candidate(c: WorkCandidate) -> str: """ADR §5.3 routing: which role should take this work next.""" if c.kind == "pr": if c.approval_contaminated: return ROLE_RECONCILER if c.request_changes_current_head: return ROLE_AUTHOR if c.approval_stale: return ROLE_REVIEWER if c.approval_on_current_head and c.mergeable: return ROLE_MERGER # Open PR without terminal verdict → reviewer return ROLE_REVIEWER # Issues: ready work → author by default; blocked stays controller/none labels = set(c.labels) if "status:blocked" in labels or c.blocked: return ROLE_CONTROLLER return ROLE_AUTHOR def role_actions(role: str) -> tuple[tuple[str, ...], tuple[str, ...]]: return ROLE_ACTIONS.get(role, ROLE_ACTIONS[ROLE_AUTHOR]) def classify_skip( c: WorkCandidate, *, role: str, terminal_pr: int | None, ) -> str | None: """Return skip reason, or None if candidate is selectable for *role*.""" if c.state in ("merged", "closed"): return f"{c.kind}#{c.number} is {c.state}; never assign" if c.blocked or "status:blocked" in c.labels: return f"{c.kind}#{c.number} is blocked" if c.dependency_unmet: return ( c.dependency_reason or f"{c.kind}#{c.number} has unmet dependencies" ) if c.already_claimed_elsewhere: return f"{c.kind}#{c.number} already claimed elsewhere" if c.kind == "pr" and not (c.head_sha or "").strip(): return f"pr#{c.number} missing head_sha pin" # Terminal path first: when an active terminal PR exists, only that PR # (or controller diagnosis) is assignable for review-path roles. if terminal_pr is not None and c.kind == "pr" and c.number != terminal_pr: if role in (ROLE_REVIEWER, ROLE_MERGER): return ( f"pr#{c.number} skipped: active terminal-review lock on " f"PR #{terminal_pr} must be resolved first" ) expected = expected_role_for_candidate(c) if role == ROLE_CONTROLLER: # Controller may inspect anything but only assigns diagnosis targets # when contaminated / blocked. if expected == ROLE_RECONCILER or c.blocked: return None return f"{c.kind}#{c.number} does not require controller (expected {expected})" if role != expected: return ( f"{c.kind}#{c.number} expects role '{expected}', active role is '{role}'" ) # Ready-gate for issues: prefer status:ready when labels present. if c.kind == "issue" and c.labels: if "status:ready" not in c.labels and "status:in-progress" not in c.labels: # Allow unlabeled open issues; only skip explicit non-ready states. if any(l.startswith("status:") for l in c.labels): return f"issue#{c.number} not status:ready ({','.join(c.labels)})" return None def sort_candidates(candidates: Sequence[WorkCandidate]) -> list[WorkCandidate]: """Higher priority first; then lower number (older issues) for stability.""" return sorted( candidates, key=lambda c: (-int(c.priority), c.kind != "pr", int(c.number)), ) def allocate_next_work( db: ControlPlaneDB, *, session_id: str, role: str, remote: str, org: str, repo: str, candidates: Sequence[WorkCandidate], apply: bool = False, profile_name: str | None = None, username: str | None = None, lease_ttl_seconds: int | None = None, ) -> dict[str, Any]: """Select and optionally reserve the next work unit via control-plane DB. *apply=False* (default): dry-run selection only — no lease/assignment. *apply=True*: atomic ``assign_and_lease`` for the selected candidate. Never uses file locks or comment-only leases as the assignment source. """ if db is None: return { "success": False, "outcome": OUTCOME_NO_SAFE, "reasons": [ "control-plane DB substrate unavailable (fail closed, #600/#613)" ], "skipped": [], "assignment": None, "substrate": "control_plane_db", "file_lock_only": False, "comment_lease_only": False, } try: role_norm = normalize_role(role, profile_name=profile_name) except ControlPlaneError as exc: return { "success": False, "outcome": OUTCOME_ROLE_INELIGIBLE, "reasons": [str(exc)], "skipped": [], "assignment": None, "substrate": "control_plane_db", } session_id = (session_id or "").strip() or f"alloc-{uuid.uuid4().hex[:12]}" try: db.upsert_session( session_id=session_id, role=role_norm, profile=profile_name, pid=os.getpid(), ) except Exception as exc: # noqa: BLE001 — surface structured return { "success": False, "outcome": OUTCOME_NO_SAFE, "reasons": [ f"failed to register session in control-plane DB: {exc} " "(fail closed, #613)" ], "skipped": [], "assignment": None, "substrate": "control_plane_db", } # Expire stale leases globally before selection. try: db.expire_stale_leases() except Exception as exc: # noqa: BLE001 return { "success": False, "outcome": OUTCOME_NO_SAFE, "reasons": [f"lease expiry failed: {exc} (fail closed)"], "skipped": [], "assignment": None, "substrate": "control_plane_db", } terminal = None try: terminal = db.get_active_terminal_lock(remote=remote, org=org, repo=repo) except Exception as exc: # noqa: BLE001 return { "success": False, "outcome": OUTCOME_NO_SAFE, "reasons": [f"terminal lock lookup failed: {exc} (fail closed)"], "skipped": [], "assignment": None, "substrate": "control_plane_db", } terminal_pr = int(terminal["terminal_pr"]) if terminal else None skipped: list[SkipRecord] = [] ordered = sort_candidates(list(candidates)) selected: WorkCandidate | None = None for c in ordered: reason = classify_skip(c, role=role_norm, terminal_pr=terminal_pr) if reason: skipped.append(SkipRecord(c.kind, c.number, reason)) continue selected = c break if selected is None: # If terminal lock blocks all review work, surface that explicitly. if terminal_pr is not None and role_norm in (ROLE_REVIEWER, ROLE_MERGER): outcome = OUTCOME_BLOCKED_TERMINAL reasons = [ f"no safe work for role '{role_norm}': active terminal-review " f"lock on PR #{terminal_pr} (resolve terminal path first, #332/#600)" ] else: outcome = OUTCOME_NO_SAFE reasons = [ f"no safe assignable work for role '{role_norm}' " f"among {len(ordered)} candidates" ] return { "success": True, "outcome": outcome, "apply": bool(apply), "role": role_norm, "profile_name": profile_name, "username": username, "session_id": session_id, "remote": remote, "org": org, "repo": repo, "selected": None, "expected_role_next": None, "reasons": reasons, "skipped": [s.as_dict() for s in skipped], "terminal_pr": terminal_pr, "assignment": None, "substrate": "control_plane_db", "file_lock_only": False, "comment_lease_only": False, "downstream_note": ( "#612 incident bridge remains downstream of #600; " "allocator never assigns raw monitoring incidents" ), } expected_role = expected_role_for_candidate(selected) allowed, forbidden = role_actions(role_norm) selection = { "kind": selected.kind, "number": selected.number, "title": selected.title, "labels": list(selected.labels), "head_sha": selected.head_sha, "priority": selected.priority, "expected_role_next": expected_role, "reason_selected": ( f"highest-priority candidate for role '{role_norm}' " f"(expected_role={expected_role})" ), } if not apply: return { "success": True, "outcome": OUTCOME_PREVIEW, "apply": False, "role": role_norm, "profile_name": profile_name, "username": username, "session_id": session_id, "remote": remote, "org": org, "repo": repo, "selected": selection, "expected_role_next": expected_role, "reasons": [ "dry-run only (apply=false); no assignment/lease created — " "call again with apply=true to reserve via control-plane DB" ], "skipped": [s.as_dict() for s in skipped], "terminal_pr": terminal_pr, "assignment": None, "substrate": "control_plane_db", "file_lock_only": False, "comment_lease_only": False, "downstream_note": ( "#612 incident bridge remains downstream of #600; " "allocator never assigns raw monitoring incidents" ), } # Atomic reserve via #613 substrate. ttl = lease_ttl_seconds if lease_ttl_seconds is not None else None try: kwargs: dict[str, Any] = { "session_id": session_id, "role": role_norm, "remote": remote, "org": org, "repo": repo, "kind": selected.kind, "number": selected.number, "expected_head_sha": selected.head_sha, "allowed_actions": allowed, "forbidden_actions": forbidden, "phase": "allocated", } if ttl is not None: kwargs["lease_ttl_seconds"] = int(ttl) result = db.assign_and_lease(**kwargs) except (InvalidWorkKindError, LeaseRequiredError, ControlPlaneError) as exc: return { "success": False, "outcome": OUTCOME_NO_SAFE, "apply": True, "role": role_norm, "session_id": session_id, "remote": remote, "org": org, "repo": repo, "selected": selection, "expected_role_next": expected_role, "reasons": [f"atomic assign+lease failed: {exc} (fail closed, #613)"], "skipped": [s.as_dict() for s in skipped], "terminal_pr": terminal_pr, "assignment": None, "substrate": "control_plane_db", "file_lock_only": False, "comment_lease_only": False, } if result.outcome == "wait": return { "success": True, "outcome": OUTCOME_WAIT, "apply": True, "role": role_norm, "session_id": session_id, "remote": remote, "org": org, "repo": repo, "selected": selection, "expected_role_next": expected_role, "reasons": [result.reason or "foreign active lease"], "skipped": [s.as_dict() for s in skipped], "terminal_pr": terminal_pr, "assignment": result.as_dict(), "owner_session_id": result.owner_session_id, "substrate": "control_plane_db", "file_lock_only": False, "comment_lease_only": False, } if result.outcome == "no_safe_work": return { "success": True, "outcome": OUTCOME_NO_SAFE, "apply": True, "role": role_norm, "session_id": session_id, "remote": remote, "org": org, "repo": repo, "selected": selection, "expected_role_next": expected_role, "reasons": [result.reason or "no_safe_work"], "skipped": [s.as_dict() for s in skipped], "terminal_pr": terminal_pr, "assignment": result.as_dict(), "substrate": "control_plane_db", "file_lock_only": False, "comment_lease_only": False, } # assigned return { "success": True, "outcome": OUTCOME_ASSIGNED, "apply": True, "role": role_norm, "profile_name": profile_name, "username": username, "session_id": session_id, "remote": remote, "org": org, "repo": repo, "selected": selection, "expected_role_next": expected_role, "reasons": [ selection["reason_selected"], result.reason or "atomic assign+lease created", ], "skipped": [s.as_dict() for s in skipped], "terminal_pr": terminal_pr, "assignment": result.as_dict(), "lease_proof": { "assignment_id": result.assignment_id, "lease_id": result.lease_id, "expires_at": result.expires_at, "expected_head_sha": result.expected_head_sha, "allowed_actions": list(result.allowed_actions), "forbidden_actions": list(result.forbidden_actions), "source": "control_plane_db.assign_and_lease", }, "next_valid_command": _next_command(role_norm, selected), "substrate": "control_plane_db", "file_lock_only": False, "comment_lease_only": False, "downstream_note": ( "#612 incident bridge remains downstream of #600; " "allocator never assigns raw monitoring incidents" ), } def _next_command(role: str, c: WorkCandidate) -> str: if role == ROLE_AUTHOR and c.kind == "issue": return f"implement issue #{c.number} under a branches/ worktree; open PR when ready" if role == ROLE_AUTHOR and c.kind == "pr": return ( f"address REQUEST_CHANGES on PR #{c.number} at head " f"{(c.head_sha or '')[:12]} and push fixes" ) if role == ROLE_REVIEWER: return ( f"review PR #{c.number} pinned at head {(c.head_sha or '')[:12]} " "via full reviewer workflow" ) if role == ROLE_MERGER: return ( f"merge PR #{c.number} only with explicit operator MERGE " f"authorization at head {(c.head_sha or '')[:12]}" ) if role == ROLE_RECONCILER: return f"diagnose contested state for {c.kind}#{c.number}" return f"proceed on {c.kind}#{c.number} under role {role}" def candidate_from_dict(data: dict[str, Any]) -> WorkCandidate: """Build a WorkCandidate from a plain dict (tests / MCP inventory).""" return WorkCandidate( kind=str(data.get("kind") or "issue"), number=int(data["number"]), state=str(data.get("state") or "open"), labels=tuple(data.get("labels") or ()), title=str(data.get("title") or ""), priority=int(data.get("priority") or 0), head_sha=data.get("head_sha"), request_changes_current_head=bool(data.get("request_changes_current_head")), approval_on_current_head=bool(data.get("approval_on_current_head")), approval_stale=bool(data.get("approval_stale")), approval_contaminated=bool(data.get("approval_contaminated")), mergeable=bool(data.get("mergeable")), blocked=bool(data.get("blocked")), dependency_unmet=bool(data.get("dependency_unmet")), dependency_reason=data.get("dependency_reason"), already_claimed_elsewhere=bool(data.get("already_claimed_elsewhere")), )