"""Pre-task role/session router (#206). Classifies a declared task type against the active MCP profile/session and returns a route result before any downstream mutation tools run. """ from __future__ import annotations import os ROUTE_ALLOWED = "allowed_current_session" ROUTE_WRONG_ROLE = "wrong_role_stop" ROUTE_TO_AUTHOR = "route_to_author_session" ROUTE_TO_REVIEWER = "route_to_reviewer_session" ROUTE_AMBIGUOUS = "ambiguous_task_stop" ROUTE_INFRA_STOP = "infra_stop" _CONFLICT_HEAD = b"<" * 7 + b" " _CONFLICT_TAIL = b">" * 7 + b" " _CONFLICT_SEPARATOR = b"=" * 7 def python_bytes_have_conflict_markers(content: bytes) -> bool: """Return True when *content* contains git merge-conflict marker lines.""" for line in content.splitlines(): stripped = line.rstrip(b"\r\n") if stripped.startswith(_CONFLICT_HEAD): return True if stripped.startswith(_CONFLICT_TAIL): return True if stripped == _CONFLICT_SEPARATOR: return True return False REVIEWER_TASKS = frozenset({ "review_pr", "merge_pr", "blind_pr_queue_review", "request_changes_pr", "approve_pr", }) AUTHOR_TASKS = frozenset({ "create_issue", "comment_issue", "close_issue", "claim_issue", "create_branch", "push_branch", "create_pr", "comment_pr", "address_pr_change_requests", "delete_branch", }) TASK_REQUIRED_ROLE = { "create_issue": "author", "comment_issue": "author", "close_issue": "author", "claim_issue": "author", "create_branch": "author", "push_branch": "author", "create_pr": "author", "comment_pr": "author", "address_pr_change_requests": "author", "delete_branch": "author", "review_pr": "reviewer", "merge_pr": "reviewer", "blind_pr_queue_review": "reviewer", "request_changes_pr": "reviewer", "approve_pr": "reviewer", } WRONG_ROLE_REVIEWER_MSG = ( "Wrong role/session for reviewer task. Launch reviewer MCP namespace." ) _session_last_route: dict | None = None def required_role_for_task(task_type: str) -> str | None: return TASK_REQUIRED_ROLE.get((task_type or "").strip()) def route_task_session( task_type: str, *, active_profile: str, active_role_kind: str, allowed_in_current_session: bool, runtime_switching_supported: bool = False, ) -> dict: """Return routing verdict for *task_type* under the active session.""" task_type = (task_type or "").strip() required_role = required_role_for_task(task_type) if required_role is None: result = { "task_type": task_type, "required_role": None, "active_role": active_role_kind, "active_profile": active_profile, "route_result": ROUTE_AMBIGUOUS, "downstream_allowed": False, "reasons": [ f"unknown task type '{task_type}'; cannot route session " "(fail closed)" ], "message": ( "Ambiguous task type; relaunch with an explicit task before " "any tool use." ), } _record_route(result) return result if required_role == "reviewer" and check_mid_merge(): result = { "task_type": task_type, "required_role": required_role, "active_role": active_role_kind, "active_profile": active_profile, "route_result": ROUTE_INFRA_STOP, "downstream_allowed": False, "reasons": [ "infra_stop: Unresolved merge conflict or mid-merge state detected in MCP runtime source.", "Please resolve all conflicts manually, finish/abort the merge, restart the MCP server, and retry." ], "message": "infra_stop: Unresolved merge conflict or mid-merge state detected in MCP runtime source.", } _record_route(result) return result if allowed_in_current_session: result = { "task_type": task_type, "required_role": required_role, "active_role": active_role_kind, "active_profile": active_profile, "route_result": ROUTE_ALLOWED, "downstream_allowed": True, "reasons": [], "message": "Task role matches active session; proceed.", } _record_route(result) return result if required_role == "reviewer": result = { "task_type": task_type, "required_role": required_role, "active_role": active_role_kind, "active_profile": active_profile, "route_result": ROUTE_WRONG_ROLE, "downstream_allowed": False, "reasons": [ WRONG_ROLE_REVIEWER_MSG, "Reviewer tasks cannot run in author-bound sessions.", "Static-profile mode does not permit in-place role switching.", ], "message": WRONG_ROLE_REVIEWER_MSG, "runtime_switching_supported": runtime_switching_supported, "profile_switch_blocked": not runtime_switching_supported, } _record_route(result) return result if required_role == "author": route = ROUTE_TO_AUTHOR message = ( "Wrong role/session for author task. Launch author MCP namespace." ) result = { "task_type": task_type, "required_role": required_role, "active_role": active_role_kind, "active_profile": active_profile, "route_result": route, "downstream_allowed": False, "reasons": [message], "message": message, "runtime_switching_supported": runtime_switching_supported, "profile_switch_blocked": not runtime_switching_supported, } _record_route(result) return result result = { "task_type": task_type, "required_role": required_role, "active_role": active_role_kind, "active_profile": active_profile, "route_result": ROUTE_AMBIGUOUS, "downstream_allowed": False, "reasons": ["unable to classify task role (fail closed)"], "message": "Ambiguous task type; stop before any mutation.", } _record_route(result) return result def last_route() -> dict | None: return _session_last_route def clear_route_state(): global _session_last_route _session_last_route = None def _record_route(result: dict): global _session_last_route _session_last_route = dict(result) def sync_route_from_capability(capability: dict) -> None: """Align sticky route state with operation-scoped capability resolution (#228).""" capability = capability or {} task = (capability.get("requested_task") or "").strip() required_role = capability.get("required_role_kind") if not task or not required_role: return if capability.get("allowed_in_current_session"): _record_route({ "task_type": task, "required_role": required_role, "active_role": required_role, "active_profile": capability.get("active_profile"), "route_result": ROUTE_ALLOWED, "downstream_allowed": True, "reasons": [], "message": ( f"Operation-scoped task '{task}' resolved for current session; " "proceed." ), }) return if required_role == "reviewer" and capability.get("stop_required"): _record_route({ "task_type": task, "required_role": required_role, "active_role": capability.get("required_role_kind"), "active_profile": capability.get("active_profile"), "route_result": ROUTE_WRONG_ROLE, "downstream_allowed": False, "reasons": [WRONG_ROLE_REVIEWER_MSG], "message": WRONG_ROLE_REVIEWER_MSG, }) def check_author_mutation_after_reviewer_stop(mutation_task: str) -> tuple[bool, list[str]]: """Block author-side fallback after a reviewer wrong_role_stop (#206). An explicit operation-scoped author capability resolution for the same *mutation_task* clears the sticky reviewer denial (#228). """ last = _session_last_route if not last: return True, [] if ( last.get("route_result") == ROUTE_ALLOWED and last.get("task_type") == mutation_task and last.get("required_role") == "author" ): return True, [] if last.get("route_result") != ROUTE_WRONG_ROLE: return True, [] if last.get("required_role") != "reviewer": return True, [] if mutation_task in AUTHOR_TASKS: return False, [ WRONG_ROLE_REVIEWER_MSG, "Author-side mutations are blocked after a reviewer-task " "wrong_role_stop unless the operator explicitly resolves the " "author task via gitea_resolve_task_capability.", f"Attempted fallback mutation: {mutation_task}", ] return True, [] def first_conflict_marker_path(project_root: str | None = None) -> str | None: """Return the first .py path containing a git conflict marker, or None.""" root_dir = project_root or os.path.dirname(os.path.abspath(__file__)) for root, dirs, files in os.walk(root_dir): if any(p in root for p in ("venv", ".git", ".pytest_cache", "branches")): continue for file in files: if not file.endswith(".py"): continue file_path = os.path.join(root, file) try: with open(file_path, "rb") as f: if python_bytes_have_conflict_markers(f.read()): return file_path except OSError: pass return None def check_mid_merge() -> bool: """Return True if the repository is mid-merge, mid-rebase, or has conflict markers.""" project_root = os.path.dirname(os.path.abspath(__file__)) git_dir = os.path.join(project_root, ".git") if os.path.exists(git_dir): if (os.path.exists(os.path.join(git_dir, "MERGE_HEAD")) or os.path.exists(os.path.join(git_dir, "rebase-merge")) or os.path.exists(os.path.join(git_dir, "rebase-apply"))): return True return first_conflict_marker_path(project_root) is not None