gitea_cleanup_post_merge_moot_lease (#515) posts a terminal `phase: released` lease marker — a durable mutation of the PR lease ledger — but had no entry in the canonical task-capability map and no role binding. Entry gated on gitea.read, apply gated only on gitea.pr.comment, so any profile holding the comment permission (author, reviewer, merger) reached the mutation path, while the reconciler could not satisfy the operator-required resolve-exact-task -> mutation sequence because no cleanup task was resolvable at all. The apply path also called verify_preflight_purity(remote) with no task/org/repo, skipping the resolved-task, canonical-root and explicit-target checks its siblings perform, and passed request org/repo straight into _resolve. Capability map and router: - Map `cleanup_post_merge_moot_lease` and the tool-name alias `gitea_cleanup_post_merge_moot_lease` to gitea.pr.comment + reconciler. Both names carry an identical contract; unknown names keep failing closed on the map's KeyError. - Add both to role_session_router RECONCILER_TASKS and TASK_REQUIRED_ROLE so the map and the router cannot disagree (the #723 defect-A class). Tool enforcement (apply path only): - Require the session to have resolved exactly the cleanup task; resolving a different task, including a sibling reconciler task, does not authorize it. - Require the reconciler role, checked independently of the permission gate. - Require gitea.pr.comment. - Validate explicit org/repo against the canonical repository identity derived from the session binding, so request parameters can never redirect the mutation, and forward worktree_path/task/org/repo to verify_preflight_purity for canonical-root, workspace and anti-stomp binding (#733/#739). - Require matching dry-run evidence proving lease_moot and cleanup_allowed for the same PR, lease session, candidate head and lease marker id, with optional caller expectations checked against the live lease. New post_merge_moot_lease_gate module holds the pure authorization logic and an append-only dry-run ledger: entries are only ever appended, lookup is newest-wins, and dry_run_history hands out copies. Live, non-moot, superseded, mismatched, malformed and foreign-repository leases all fail closed; already-terminal cleanup stays idempotent. The read-only apply=false assessment deliberately stays reachable under gitea.read with no role gate, matching gitea_cleanup_stale_review_decision_lock and gitea_cleanup_obsolete_reviewer_comment_lease, so an operator can diagnose a stuck lease from any attached namespace. This choice is documented in the map, the tool docstring and docs/gitea-execution-profiles.md, and is directly tested. _delete_branch_repository_binding_block is generalized into _repository_binding_block(required_permission=...) and retained as a thin delete-path alias so #733/#739 coverage keeps exercising its permission label. Tests: new tests/test_issue_745_moot_lease_reconciler_gate.py (39 tests, 35 subtests) covers the map/router contract, alias parity, unknown-name rejection, role and task gates, dry-run/apply sequencing, superseded and malformed leases, repository binding, ledger append-only behavior, idempotency, and asserts no production PR/session/marker is referenced. tests/test_post_merge_moot_lease.py is updated from the permission-only model to reconciler + dry-run evidence, with a new negative test pinning that a merger can no longer apply. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
453 lines
16 KiB
Python
453 lines
16 KiB
Python
"""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
|
|
|
|
|
|
def skip_python_scan_walk_root(project_root: str, walk_root: str) -> bool:
|
|
"""Skip venv/git/cache and sibling worktrees under orchestration checkout.
|
|
|
|
When *project_root* is itself a worktree inside ``branches/``, still scan
|
|
that tree — do not treat the ``branches`` path segment as a skip signal.
|
|
"""
|
|
rel = os.path.relpath(walk_root, project_root)
|
|
if rel == ".":
|
|
return False
|
|
head = rel.split(os.sep, 1)[0]
|
|
if head in ("venv", ".git", ".pytest_cache"):
|
|
return True
|
|
if head == "branches":
|
|
nested = os.path.join(project_root, "branches")
|
|
if os.path.isdir(nested) and (
|
|
walk_root == nested or walk_root.startswith(nested + os.sep)
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
REVIEWER_TASKS = frozenset({
|
|
"review_pr",
|
|
"blind_pr_queue_review",
|
|
"pr_queue_cleanup",
|
|
"pr-queue-cleanup",
|
|
"request_changes_pr",
|
|
"approve_pr",
|
|
})
|
|
|
|
MERGER_TASKS = frozenset({
|
|
"merge_pr",
|
|
})
|
|
|
|
AUTHOR_TASKS = frozenset({
|
|
"create_issue",
|
|
"comment_issue",
|
|
"close_issue",
|
|
"claim_issue",
|
|
"create_branch",
|
|
"push_branch",
|
|
"create_pr",
|
|
"comment_pr",
|
|
"address_pr_change_requests",
|
|
"work_issue",
|
|
"work-issue",
|
|
"reconcile_landed_pr",
|
|
})
|
|
|
|
RECONCILER_TASKS = frozenset({
|
|
"cleanup_merged_pr_branch",
|
|
# #729: delete_branch is reconciler-owned (gitea.branch.delete is granted
|
|
# only to the reconciler profile). Raw gitea_delete_branch redirects here to
|
|
# the guarded gitea_cleanup_merged_pr_branch path (#514/#687).
|
|
"delete_branch",
|
|
# #745: post-merge moot reviewer-lease cleanup is reconciler-owned; the
|
|
# apply path posts a terminal lease marker. Kept in step with
|
|
# task_capability_map so map and router cannot disagree (#723 defect A).
|
|
"cleanup_post_merge_moot_lease",
|
|
"gitea_cleanup_post_merge_moot_lease",
|
|
"reconcile_already_landed_pr",
|
|
"reconcile_already_landed",
|
|
"reconcile-landed-pr",
|
|
})
|
|
|
|
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",
|
|
# #729: reconciler-owned; see RECONCILER_TASKS and task_capability_map.
|
|
"delete_branch": "reconciler",
|
|
"review_pr": "reviewer",
|
|
"merge_pr": "merger",
|
|
"blind_pr_queue_review": "reviewer",
|
|
"pr_queue_cleanup": "reviewer",
|
|
"pr-queue-cleanup": "reviewer",
|
|
"request_changes_pr": "reviewer",
|
|
"approve_pr": "reviewer",
|
|
"work_issue": "author",
|
|
"work-issue": "author",
|
|
"reconcile_landed_pr": "author",
|
|
"reconcile_already_landed_pr": "reconciler",
|
|
"reconcile_already_landed": "reconciler",
|
|
"reconcile-landed-pr": "reconciler",
|
|
"cleanup_merged_pr_branch": "reconciler",
|
|
# #745: post-merge moot reviewer-lease cleanup (canonical task + tool alias).
|
|
"cleanup_post_merge_moot_lease": "reconciler",
|
|
"gitea_cleanup_post_merge_moot_lease": "reconciler",
|
|
# #309: reconciler tasks close already-landed PRs/issues only.
|
|
"reconcile_close_landed_pr": "reconciler",
|
|
"reconcile_close_landed_issue": "reconciler",
|
|
"reconcile_close_superseded_pr": "reconciler",
|
|
"reconcile_close_satisfied_issue": "reconciler",
|
|
"reconcile_create_followup_issue": "reconciler",
|
|
}
|
|
|
|
WRONG_ROLE_REVIEWER_MSG = (
|
|
"Wrong role/session for reviewer task. Launch reviewer MCP namespace."
|
|
)
|
|
|
|
WRONG_ROLE_RECONCILER_MSG = (
|
|
"Wrong role/session for reconciler task. Launch a reconciler-capable "
|
|
"MCP namespace/profile with exact close capability."
|
|
)
|
|
|
|
WRONG_ROLE_MERGER_MSG = (
|
|
"Wrong role/session for merger task. Launch merger 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":
|
|
infra = assess_infra_stop()
|
|
if infra["infra_stop"]:
|
|
detail = "; ".join(infra.get("infra_stop_reasons") or [])
|
|
message = (
|
|
"infra_stop: Unresolved merge conflict or mid-merge state detected "
|
|
f"in MCP runtime source ({detail}). Please resolve all conflicts "
|
|
"manually, finish/abort the merge, and retry."
|
|
)
|
|
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": [message],
|
|
"message": message,
|
|
"infra_stop_assessment": infra,
|
|
}
|
|
_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 == "reconciler":
|
|
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_RECONCILER_MSG,
|
|
"Reconciler tasks cannot run in author or reviewer "
|
|
"sessions without exact close capability.",
|
|
],
|
|
"message": WRONG_ROLE_RECONCILER_MSG,
|
|
"runtime_switching_supported": runtime_switching_supported,
|
|
"profile_switch_blocked": not runtime_switching_supported,
|
|
}
|
|
_record_route(result)
|
|
return result
|
|
|
|
if required_role == "merger":
|
|
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_MERGER_MSG,
|
|
"Merger tasks cannot run in author or reviewer sessions.",
|
|
],
|
|
"message": WRONG_ROLE_MERGER_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 skip_python_scan_walk_root(root_dir, root):
|
|
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 _default_project_root() -> str:
|
|
override = (os.environ.get("GITEA_MCP_PROJECT_ROOT") or "").strip()
|
|
if override:
|
|
return os.path.realpath(override)
|
|
return os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
|
def assess_infra_stop(project_root: str | None = None) -> dict:
|
|
"""Recompute infra_stop from live git and source scan state (#285)."""
|
|
root_dir = os.path.realpath(project_root or _default_project_root())
|
|
reasons: list[str] = []
|
|
mid_merge = False
|
|
git_dir = os.path.join(root_dir, ".git")
|
|
if os.path.isdir(git_dir):
|
|
for marker in ("MERGE_HEAD", "rebase-merge", "rebase-apply"):
|
|
marker_path = os.path.join(git_dir, marker)
|
|
if os.path.exists(marker_path):
|
|
mid_merge = True
|
|
reasons.append(f"git state: {marker} present under {git_dir}")
|
|
conflict_file = first_conflict_marker_path(root_dir)
|
|
if conflict_file:
|
|
reasons.append(
|
|
f"conflict markers detected in {conflict_file} (project_root={root_dir})"
|
|
)
|
|
infra_stop = mid_merge or bool(conflict_file)
|
|
return {
|
|
"infra_stop": infra_stop,
|
|
"project_root": root_dir,
|
|
"conflict_file": conflict_file,
|
|
"mid_merge": mid_merge,
|
|
"infra_stop_reasons": reasons,
|
|
}
|
|
|
|
|
|
def check_mid_merge(project_root: str | None = None) -> bool:
|
|
"""Return True if the repository is mid-merge, mid-rebase, or has conflict markers."""
|
|
return assess_infra_stop(project_root)["infra_stop"]
|