Merge pull request 'feat(profiles): support reconciler cleanup capability and migration (Closes #687)' (#688) from feat/issue-687-reconciler-branch-delete into master

This commit was merged in pull request #688.
This commit is contained in:
2026-07-16 03:23:03 -05:00
10 changed files with 2810 additions and 107 deletions
+654 -62
View File
@@ -7586,6 +7586,65 @@ def gitea_delete_branch(
"permission_report": _permission_block_report("gitea.branch.delete"),
}
# Possessing gitea.branch.delete alone is not enough for arbitrary deletion.
# task_capability_map maps delete_branch → author; reconciler must use the
# guarded cleanup_merged_pr_branch path only (#687 / #514).
profile = get_profile()
active_role = _profile_role_kind(profile)
required_role = task_capability_map.required_role("delete_branch")
if active_role == "reconciler":
return {
"success": False,
"performed": False,
"required_permission": "gitea.branch.delete",
"required_role_kind": required_role,
"active_role_kind": active_role,
"reasons": [
"reconciler profile cannot use raw gitea_delete_branch; "
"use gitea_cleanup_merged_pr_branch for a fully merged PR "
"source branch only (fail closed)"
],
"exact_next_action": (
"Call gitea_cleanup_merged_pr_branch with pr_number, the "
"exact PR head branch, and confirmation "
"'CLEANUP MERGED PR <n> BRANCH <branch>' after capability "
"resolve for cleanup_merged_pr_branch."
),
"permission_report": _permission_block_report("gitea.branch.delete"),
}
if active_role != required_role:
return {
"success": False,
"performed": False,
"required_permission": "gitea.branch.delete",
"required_role_kind": required_role,
"active_role_kind": active_role,
"reasons": [
f"Active profile role '{active_role}' cannot perform "
f"{required_role} task 'delete_branch' even when "
"gitea.branch.delete is present (fail closed)"
],
"permission_report": _permission_block_report("gitea.branch.delete"),
}
if branch_cleanup_guard.is_preservation_or_evidence_branch(branch):
return {
"success": False,
"performed": False,
"required_permission": "gitea.branch.delete",
"reasons": [
f"branch '{branch}' is a preservation/evidence branch and "
"cannot be deleted (fail closed)"
],
}
if branch in branch_cleanup_guard.PROTECTED_BRANCHES:
return {
"success": False,
"performed": False,
"required_permission": "gitea.branch.delete",
"reasons": [f"branch '{branch}' is protected (fail closed)"],
}
audit_allowed, audit_reasons = (
audit_reconciliation_mode.check_audit_mutation_allowed("delete_branch")
)
@@ -7628,41 +7687,49 @@ def gitea_cleanup_merged_pr_branch(
"""Delete a merged PR source branch through the guarded MCP path (#514)."""
gate_reasons = _profile_operation_gate("gitea.branch.delete")
if gate_reasons:
return {
"success": False,
"performed": False,
"required_permission": "gitea.branch.delete",
"reasons": gate_reasons,
"permission_report": _permission_block_report("gitea.branch.delete"),
}
return branch_cleanup_guard.cleanup_result_envelope(
success=False,
performed=False,
delete_acknowledged=False,
verified_absent=False,
required_permission="gitea.branch.delete",
reasons=gate_reasons,
permission_report=_permission_block_report("gitea.branch.delete"),
)
profile = get_profile()
active_role = _role_kind(
profile.get("allowed_operations", []),
profile.get("forbidden_operations", []),
)
if active_role == "reviewer":
return {
"success": False,
"performed": False,
"required_permission": "gitea.branch.delete",
"reasons": [
"reviewer profile is not authorized for merged branch cleanup "
"(fail closed)"
active_role = _profile_role_kind(profile)
# cleanup_merged_pr_branch is reconciler-owned (task_capability_map).
# Author/reviewer/merger must not reach this path even if they somehow
# hold gitea.branch.delete.
if active_role != "reconciler":
return branch_cleanup_guard.cleanup_result_envelope(
success=False,
performed=False,
delete_acknowledged=False,
verified_absent=False,
required_permission="gitea.branch.delete",
required_role_kind="reconciler",
active_role_kind=active_role,
reasons=[
f"profile role '{active_role}' is not authorized for merged "
"branch cleanup; required role is reconciler (fail closed)"
],
"permission_report": _permission_block_report("gitea.branch.delete"),
}
permission_report=_permission_block_report("gitea.branch.delete"),
)
if worktree_path is None or "/branches/" not in os.path.realpath(worktree_path):
return {
"success": False,
"performed": False,
"required_permission": "gitea.branch.delete",
"reasons": [
return branch_cleanup_guard.cleanup_result_envelope(
success=False,
performed=False,
delete_acknowledged=False,
verified_absent=False,
required_permission="gitea.branch.delete",
reasons=[
"merged branch cleanup requires an explicit branches/ worktree "
"path; root checkout branch ref mutation is blocked (fail closed)"
],
}
)
verify_preflight_purity(
remote,
@@ -7680,16 +7747,18 @@ def gitea_cleanup_merged_pr_branch(
target_branch = (pr.get("base") or {}).get("ref") or "master"
if branch and branch != pr_head.get("ref"):
return {
"success": False,
"performed": False,
"pr_number": pr_number,
"branch": branch,
"reasons": [
return branch_cleanup_guard.cleanup_result_envelope(
success=False,
performed=False,
delete_acknowledged=False,
verified_absent=False,
pr_number=pr_number,
branch=branch,
reasons=[
f"requested branch '{branch}' does not match PR head "
f"'{pr_head.get('ref')}'"
],
}
)
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
open_heads = {
@@ -7719,19 +7788,86 @@ def gitea_cleanup_merged_pr_branch(
confirmation=confirmation,
)
if not assessment["safe_to_delete"]:
return {
"success": False,
"performed": False,
"pr_number": pr_number,
"branch": head_branch,
"assessment": assessment,
"reasons": assessment["block_reasons"],
}
return branch_cleanup_guard.cleanup_result_envelope(
success=False,
performed=False,
delete_acknowledged=False,
verified_absent=False,
pr_number=pr_number,
branch=head_branch,
assessment=assessment,
reasons=assessment["block_reasons"],
)
# #687: active ownership protection (sessions/leases/worktree bindings).
# Do not rely only on the caller worktree living under branches/.
ownership_bundle = _collect_branch_ownership_records(
remote=remote,
host=h,
org=o,
repo=r,
branch=head_branch,
pr_number=pr_number,
project_root=PROJECT_ROOT,
auth=auth,
base_api=base,
)
ownership_records = ownership_bundle.get("records") or []
if ownership_bundle.get("inventory_error"):
# O1: control-plane / ownership inventory failure fails closed.
ownership_records = list(ownership_records) + [
{
"category": branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR,
"status": "unknown",
"remote": remote,
"host": h,
"org": o,
"repo": r,
"branch": head_branch,
"reclaim_allowed": False,
"role": "inventory",
}
]
ownership = branch_cleanup_guard.assess_active_branch_ownership(
remote=remote,
org=o,
repo=r,
branch=head_branch,
host=h,
records=ownership_records,
)
if ownership.get("block"):
return branch_cleanup_guard.cleanup_result_envelope(
success=False,
performed=False,
delete_acknowledged=False,
verified_absent=False,
pr_number=pr_number,
branch=head_branch,
assessment=assessment,
ownership={
"block": True,
"blocking_categories": ownership.get("blocking_categories") or [],
"reasons": ownership.get("reasons") or [],
"blocker_kind": ownership.get("blocker_kind"),
"checked": True,
},
reasons=ownership.get("reasons")
or ["active ownership protects the target branch"],
blocker_kind="active_branch_ownership",
)
import urllib.parse
encoded_branch = urllib.parse.quote(head_branch, safe="")
url = f"{base}/branches/{encoded_branch}"
request_metadata = {
"branch": head_branch,
"required_permission": "gitea.branch.delete",
"cleanup_path": "gitea_cleanup_merged_pr_branch",
"ownership_checked": True,
"ownership_blocking_categories": [],
}
with _audited(
"cleanup_merged_pr_branch",
host=h,
@@ -7740,36 +7876,416 @@ def gitea_cleanup_merged_pr_branch(
repo=r,
pr_number=pr_number,
target_branch=head_branch,
request_metadata={
"branch": head_branch,
"required_permission": "gitea.branch.delete",
"cleanup_path": "gitea_cleanup_merged_pr_branch",
},
request_metadata=request_metadata,
):
api_request("DELETE", url, auth)
return {
"success": True,
"performed": True,
"pr_number": pr_number,
"branch": head_branch,
"message": f"Merged PR #{pr_number} source branch '{head_branch}' deleted.",
"assessment": assessment,
# #687: authoritative post-delete readback. DELETE success alone is not
# enough — only branch-scoped not-found proves deletion (R1).
readback = _probe_remote_branch(h, o, r, auth, head_branch)
readback_assessment = branch_cleanup_guard.assess_post_delete_readback(readback)
verified_absent = bool(readback_assessment.get("verified_absent"))
request_metadata["post_delete_readback"] = {
"status": (readback_assessment.get("readback") or {}).get("status"),
"verified_absent": verified_absent,
"error_class": (readback_assessment.get("readback") or {}).get(
"error_class"
),
"not_found_scope": (readback_assessment.get("readback") or {}).get(
"not_found_scope"
),
}
if gitea_audit.audit_enabled():
_audit(
"cleanup_merged_pr_branch_readback",
host=h,
remote=remote,
org=o,
repo=r,
result=(
gitea_audit.SUCCEEDED
if readback_assessment.get("ok")
else gitea_audit.FAILED
),
reason="; ".join(readback_assessment.get("reasons") or []) or None,
request_metadata=request_metadata,
pr_number=pr_number,
target_branch=head_branch,
)
if not readback_assessment.get("ok"):
return branch_cleanup_guard.cleanup_result_envelope(
success=False,
performed=True,
delete_acknowledged=True,
verified_absent=False,
pr_number=pr_number,
branch=head_branch,
assessment=assessment,
ownership={
"block": False,
"blocking_categories": [],
"checked": True,
},
readback=readback_assessment.get("readback"),
reasons=readback_assessment.get("reasons")
or ["post-delete branch readback could not verify deletion"],
blocker_kind=readback_assessment.get("blocker_kind")
or "post_delete_readback_failed",
message=(
f"DELETE accepted for '{head_branch}' but post-delete readback "
"did not verify absence"
),
)
return branch_cleanup_guard.cleanup_result_envelope(
success=True,
performed=True,
delete_acknowledged=True,
verified_absent=True,
pr_number=pr_number,
branch=head_branch,
message=(
f"Merged PR #{pr_number} source branch '{head_branch}' deleted "
"and verified absent via branch-scoped post-delete readback."
),
assessment=assessment,
ownership={
"block": False,
"blocking_categories": [],
"checked": True,
},
readback=readback_assessment.get("readback"),
)
def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> bool:
def _probe_remote_branch(
h: str, o: str, r: str, auth: str, branch: str
) -> dict:
"""GET a remote branch and return a secret-free structured readback.
R1: On HTTP 404, re-probe the repository endpoint. Only when the repo is
still reachable is the 404 treated as branch-scoped absence. Generic,
repository-scoped, or host-level 404 never sets verified_absent.
"""
import urllib.parse
encoded = urllib.parse.quote(branch, safe="")
url = f"{repo_api_url(h, o, r)}/branches/{encoded}"
try:
api_request("GET", url, auth)
return True
return branch_cleanup_guard.classify_branch_readback_http_status(200)
except Exception as exc:
message = str(exc).lower()
if "404" in message or "not found" in message:
return False
raise
status = branch_cleanup_guard._extract_http_status(exc)
if status != 404:
return branch_cleanup_guard.classify_branch_readback_exception(exc)
# Distinguish branch vs repository/host 404 via repo reachability.
repo_url = repo_api_url(h, o, r)
try:
api_request("GET", repo_url, auth)
# Repo reachable → branch-scoped not-found.
return branch_cleanup_guard.classify_branch_readback_http_status(
404,
not_found_scope=branch_cleanup_guard.NOT_FOUND_SCOPE_BRANCH,
)
except Exception as repo_exc:
repo_status = branch_cleanup_guard._extract_http_status(repo_exc)
if repo_status == 404:
return branch_cleanup_guard.classify_branch_readback_http_status(
404,
not_found_scope=branch_cleanup_guard.NOT_FOUND_SCOPE_REPOSITORY,
)
if repo_status in (401, 407):
return branch_cleanup_guard.classify_branch_readback_http_status(
401
)
if repo_status == 403:
return branch_cleanup_guard.classify_branch_readback_http_status(
403
)
# Host/transport/unknown: not branch-verified.
return branch_cleanup_guard.classify_branch_readback_http_status(
404,
not_found_scope=branch_cleanup_guard.NOT_FOUND_SCOPE_UNKNOWN,
)
def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> bool:
"""True when the remote branch exists; False on authoritative branch 404.
Authentication, authorization, transport, and ambiguous 404 failures are
raised rather than treated as absence.
"""
probe = _probe_remote_branch(h, o, r, auth, branch)
status = probe.get("status")
if (
status == branch_cleanup_guard.READBACK_NOT_FOUND
and probe.get("verified_absent")
):
return False
if status == branch_cleanup_guard.READBACK_EXISTS:
return True
error_class = probe.get("error_class") or "unexpected"
raise RuntimeError(
f"remote branch probe failed ({error_class}/{status})"
)
def _collect_branch_ownership_records(
*,
remote: str,
host: str,
org: str,
repo: str,
branch: str,
pr_number: int | None,
project_root: str,
auth: str | None = None,
base_api: str | None = None,
) -> dict:
"""Gather canonical ownership records for *branch* (secret-free).
Sources:
- author issue locks (task-session / lease files)
- control-plane leases (author/reviewer/merger/controller/reconciler)
- comment-backed active reviewer leases (O3)
- local worktree bindings checked out to the branch
Returns ``{"records": [...], "inventory_error": bool}``. Control-plane
inventory errors set inventory_error=True so callers fail closed (O1).
"""
records: list[dict] = []
inventory_error = False
target_branch = (branch or "").strip()
if not target_branch:
return {"records": records, "inventory_error": False}
host_n = branch_cleanup_guard.normalize_host(host)
def _base_rec(**kwargs):
rec = {
"remote": remote,
"host": host_n or host,
"org": org,
"repo": repo,
"branch": target_branch,
}
rec.update(kwargs)
return rec
# --- Author issue locks (session + lease) ---
try:
for path in issue_lock_store.iter_lock_files():
lock = issue_lock_store.read_lock_file(path)
if not isinstance(lock, dict):
continue
if (
str(lock.get("remote") or "") != str(remote)
or str(lock.get("org") or "") != str(org)
or str(lock.get("repo") or "") != str(repo)
):
continue
# Host identity when present on the lock
lock_host = branch_cleanup_guard.normalize_host(
lock.get("host") or lock.get("host_name")
)
if host_n and lock_host and host_n != lock_host:
continue
if str(lock.get("branch_name") or "").strip() != target_branch:
continue
freshness = issue_lock_store.assess_lock_freshness(lock)
reclaim = issue_lock_store.assess_expired_lock_reclaim(lock)
status = str(freshness.get("status") or "unknown")
if freshness.get("live"):
status = "active"
records.append(
_base_rec(
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
status=status,
reclaim_allowed=bool(reclaim.get("reclaim_allowed")),
role="author",
)
)
if freshness.get("live"):
records.append(
_base_rec(
category=(
branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_SESSION
),
status="active",
reclaim_allowed=False,
role="author",
)
)
except Exception:
inventory_error = True
records.append(
_base_rec(
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
status="unknown",
reclaim_allowed=False,
role="author",
)
)
# --- Control-plane leases (role-tagged) ---
try:
db = None
if hasattr(control_plane_db, "get_db"):
try:
db = control_plane_db.get_db()
except Exception:
db = None
if db is None and hasattr(control_plane_db, "ControlPlaneDB"):
try:
db = control_plane_db.ControlPlaneDB()
except Exception:
db = None
inventory_error = True
if db is None:
# O1: unavailable control-plane inventory fails closed.
inventory_error = True
else:
listed = lease_lifecycle.list_active_leases(
db,
remote=remote,
org=org,
repo=repo,
include_non_active=True,
limit=200,
)
for lease in listed.get("leases") or []:
work_kind = str(lease.get("work_kind") or "")
work_number = lease.get("work_number")
lease_branch = str(
lease.get("branch")
or lease.get("branch_name")
or ""
).strip()
matches_branch = lease_branch == target_branch
matches_pr = (
work_kind == "pr"
and pr_number is not None
and int(work_number or 0) == int(pr_number)
)
if not (matches_branch or matches_pr):
continue
if matches_pr and not lease_branch:
lease_branch = target_branch
if lease_branch != target_branch:
continue
lease_host = branch_cleanup_guard.normalize_host(
lease.get("host") or lease.get("host_name")
)
if host_n and lease_host and host_n != lease_host:
continue
fr = lease.get("freshness") or lease_lifecycle.classify_lease_freshness(
lease
)
freshness_status = str(
(fr.get("freshness") if isinstance(fr, dict) else None)
or lease.get("status")
or "unknown"
)
role = str(lease.get("role") or "unknown")
category = branch_cleanup_guard.ownership_category_for_role(role)
# O2: expired never auto-receives reclaim_allowed=True.
if freshness_status == "active":
status = "active"
reclaim_allowed = False
elif freshness_status in {"released", "abandoned"}:
status = freshness_status
reclaim_allowed = True
elif freshness_status == "expired" or (
isinstance(fr, dict) and fr.get("expired_by_time")
):
status = "expired"
reclaim_allowed = False # O2 fail closed
else:
status = freshness_status
reclaim_allowed = False
records.append(
_base_rec(
category=category,
status=status,
reclaim_allowed=reclaim_allowed,
role=role,
host=lease_host or host_n or host,
)
)
except Exception:
# O1: fail closed on control-plane inventory errors.
inventory_error = True
records.append(
_base_rec(
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR,
status="unknown",
reclaim_allowed=False,
role="control_plane",
)
)
# --- O3: comment-backed active reviewer leases ---
if pr_number is not None and auth and base_api:
try:
comments = api_get_all(
f"{base_api}/issues/{int(pr_number)}/comments", auth
)
active = reviewer_pr_lease.find_active_reviewer_lease(
comments, pr_number=int(pr_number)
)
if active:
records.append(
_base_rec(
category=(
branch_cleanup_guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE
),
status="active",
reclaim_allowed=False,
role="reviewer",
)
)
except Exception:
inventory_error = True
records.append(
_base_rec(
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR,
status="unknown",
reclaim_allowed=False,
role="reviewer_comment_lease",
)
)
# --- Worktree bindings checked out to the target branch ---
try:
for entry in worktree_cleanup_audit.list_worktrees(project_root):
wt_branch = str(entry.get("branch") or "").strip()
if wt_branch != target_branch:
continue
records.append(
_base_rec(
category=(
branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING
),
status="active",
reclaim_allowed=False,
role="worktree",
)
)
except Exception:
inventory_error = True
records.append(
_base_rec(
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING,
status="unknown",
reclaim_allowed=False,
role="worktree",
)
)
return {"records": records, "inventory_error": inventory_error}
@mcp.tool()
@@ -7956,6 +8472,66 @@ def gitea_reconcile_merged_cleanups(
if remote_assessment.get("safe_to_delete_remote"):
import urllib.parse
pr_num = entry.get("pr_number")
try:
pr_num_int = int(pr_num) if pr_num is not None else None
except (TypeError, ValueError):
pr_num_int = None
ownership_bundle = _collect_branch_ownership_records(
remote=remote,
host=h,
org=o,
repo=r,
branch=head_branch,
pr_number=pr_num_int,
project_root=PROJECT_ROOT,
auth=auth,
base_api=base,
)
ownership_records = list(ownership_bundle.get("records") or [])
if ownership_bundle.get("inventory_error"):
ownership_records.append(
{
"category": (
branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR
),
"status": "unknown",
"remote": remote,
"host": h,
"org": o,
"repo": r,
"branch": head_branch,
"reclaim_allowed": False,
"role": "inventory",
}
)
ownership = branch_cleanup_guard.assess_active_branch_ownership(
remote=remote,
org=o,
repo=r,
branch=head_branch,
host=h,
records=ownership_records,
)
if ownership.get("block"):
actions.append(
{
"action": "delete_remote_branch",
"branch": head_branch,
"success": False,
"performed": False,
"delete_acknowledged": False,
"verified_absent": False,
"blocker_kind": "active_branch_ownership",
"reasons": ownership.get("reasons") or [],
"blocking_categories": ownership.get(
"blocking_categories"
)
or [],
}
)
continue
encoded = urllib.parse.quote(head_branch, safe="")
url = f"{base}/branches/{encoded}"
with _audited(
@@ -7965,14 +8541,28 @@ def gitea_reconcile_merged_cleanups(
org=o,
repo=r,
target_branch=head_branch,
request_metadata={"branch": head_branch, "source": "reconcile_merged_cleanups"},
request_metadata={
"branch": head_branch,
"source": "reconcile_merged_cleanups",
"ownership_checked": True,
},
):
api_request("DELETE", url, auth)
readback = _probe_remote_branch(h, o, r, auth, head_branch)
readback_assessment = branch_cleanup_guard.assess_post_delete_readback(
readback
)
verified = bool(readback_assessment.get("verified_absent"))
actions.append(
{
"action": "delete_remote_branch",
"branch": head_branch,
"success": True,
"success": bool(readback_assessment.get("ok")),
"performed": True,
"delete_acknowledged": True,
"verified_absent": verified,
"readback": readback_assessment.get("readback"),
"reasons": readback_assessment.get("reasons") or [],
}
)
@@ -13202,6 +13792,8 @@ def gitea_resolve_task_capability(
"gitea_commit_files",
"address_pr_change_requests",
"delete_branch",
"cleanup_merged_pr_branch",
"reconciliation_cleanup",
"work_issue",
"work-issue",
}