feat(reconciler): PR-scoped post-merge cleanup executor + expired reviewer-lease reclaim (Closes #855)

Adds a single-target path for post-merge cleanup so a reconciler can
complete one merged PR without a batch sweep across unrelated PRs.

## PR-scoped selector

gitea_reconcile_merged_cleanups gains an optional pr_number. When set,
only that merged PR is assessed and acted on: the PR is resolved live and
fails closed on an invalid/non-positive number, an unresolvable or
ambiguous PR, or an unmerged PR; reviewer scratch worktrees are filtered
to that PR; and the report's entry set is pinned to exactly [pr_number],
failing closed on any drift. The existing execute loop then operates on
the single pinned entry only -- worktree removal, ownership reassessment,
then remote-branch delete -- with no unrelated target. Batch behaviour is
unchanged when pr_number is omitted.

## Expired reviewer-lease reclaim (AC4)

An expired or stale reviewer lease no longer protects an already-merged
branch forever. branch_cleanup_guard.assess_expired_reviewer_lease_reclaim
makes the decision explicitly and fail-closed: reclaim only when the lease
is a reviewer lease, its status is expired/stale, the PR is proven merged,
the owner process is proven dead, and no competing active claimant uses
the branch. _collect_branch_ownership_records supplies that evidence from
authoritative state (live PR merged-state, lease owner liveness, and the
full ownership inventory for competing-claimant detection) and evaluates
it only after the complete inventory is built, so the post-worktree-removal
reassessment is what unblocks the branch delete. Any unknown fails closed.

Author/merger/controller/reconciler leases are untouched; active leases,
worktree bindings, issue locks, and live sessions still block.

## Tests

- tests/test_issue_855_expired_reviewer_reclaim.py: full fail-closed matrix
  for the reclaim decision plus collector wiring (merged+dead+uncontested
  reclaims; unmerged, live-owner, competing-worktree, and author-lease
  cases stay protective).
- tests/test_branch_cleanup_guard.py: exact-PR selector coverage (ignores
  newer PRs in the batch queue, execute mutates only the selected PR,
  unknown/not-merged/invalid fail closed, batch mode preserved).

Changed-surface suites pass; the 2 pre-existing test_branch_cleanup_guard
failures and test_reconciler_supersession_close reproduce identically on
master 6d0015ca and are unrelated to this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-24 00:49:52 -04:00
co-authored by Claude Opus 4.8
parent 6d0015cabc
commit 24c52abf6b
4 changed files with 893 additions and 11 deletions
+213 -11
View File
@@ -10839,6 +10839,9 @@ def _collect_branch_ownership_records(
"""
records: list[dict] = []
inventory_error = False
# #855 AC4: expired/stale reviewer-lease records eligible for an explicit
# reclaim decision, evaluated after the full ownership inventory is built.
reviewer_reclaim_candidates: list[tuple[dict, bool | None]] = []
target_branch = (branch or "").strip()
if not target_branch:
return {"records": records, "inventory_error": False}
@@ -10987,15 +10990,28 @@ def _collect_branch_ownership_records(
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,
)
rec = _base_rec(
category=category,
status=status,
reclaim_allowed=reclaim_allowed,
role=role,
host=lease_host or host_n or host,
)
records.append(rec)
# #855 AC4: a reviewer lease that is expired/stale (its owner
# gone) becomes a candidate for an explicit, fail-closed
# reclaim decision made once the full inventory is known.
if (
role == "reviewer"
and status
in branch_cleanup_guard._RECLAIMABLE_REVIEWER_STATUSES
):
owner_alive = (
fr.get("owner_pid_alive") if isinstance(fr, dict) else None
)
reviewer_reclaim_candidates.append(
(rec, owner_alive if isinstance(owner_alive, bool) else None)
)
except Exception:
# O1: fail closed on control-plane inventory errors.
inventory_error = True
@@ -11066,6 +11082,44 @@ def _collect_branch_ownership_records(
)
)
# #855 AC4: decide, explicitly and fail-closed, whether any expired/stale
# reviewer lease may stop protecting an already-merged branch. This runs
# only after the full ownership inventory is built, so a competing active
# claimant (an active lease, author session, worktree binding, or active
# reviewer comment lease) is visible. An inventory failure keeps every
# reclaim candidate protective (reclaim_allowed stays False).
if reviewer_reclaim_candidates and not inventory_error:
pr_merged_state: bool | None = None
if pr_number is not None and auth and base_api:
try:
pr_live = api_request(
"GET", f"{base_api}/pulls/{int(pr_number)}", auth
)
if isinstance(pr_live, dict) and pr_live:
pr_merged_state = bool(
pr_live.get("merged") or pr_live.get("merged_at")
)
except Exception:
# Unknown merged state fails closed (candidate stays protective).
pr_merged_state = None
for cand_rec, owner_alive in reviewer_reclaim_candidates:
competing = any(
other is not cand_rec
and branch_cleanup_guard.is_active_ownership_status(
other.get("status")
)
for other in records
)
decision = branch_cleanup_guard.assess_expired_reviewer_lease_reclaim(
role=str(cand_rec.get("role")),
status=str(cand_rec.get("status")),
pr_merged=pr_merged_state,
owner_pid_alive=owner_alive,
competing_active_claimant=competing,
)
cand_rec["reclaim_allowed"] = decision["reclaim_allowed"]
cand_rec["reclaim_decision"] = decision["decision"]
return {"records": records, "inventory_error": inventory_error}
@@ -11128,6 +11182,7 @@ def gitea_reconcile_merged_cleanups(
dry_run: bool = True,
execute_confirmed: bool = False,
limit: int = 50,
pr_number: int | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
@@ -11138,7 +11193,11 @@ def gitea_reconcile_merged_cleanups(
Args:
dry_run: Defaults to True. When True, only builds the reconciliation report.
execute_confirmed: Must be True when dry_run=False.
limit: Max number of closed PRs to inspect.
limit: Max number of closed PRs to inspect (batch mode only; ignored when
``pr_number`` is set).
pr_number: Optional exact merged PR selector (#855). When set, only that
PR is assessed/acted on (fail closed if missing, unmerged, or
ambiguous). When omitted, existing batch behaviour is preserved.
remote: Known Gitea instance ('dadeschools' or 'prgs').
host: Override the Gitea host.
org: Override the owner/organization.
@@ -11173,11 +11232,120 @@ def gitea_reconcile_merged_cleanups(
"audit_phase": audit_reconciliation_mode.current_phase(),
}
# #855: optional exact PR pin. Fail closed before any inventory mutation.
exact_pr: int | None = None
if pr_number is not None:
try:
exact_pr = int(pr_number)
except (TypeError, ValueError):
return {
"success": False,
"performed": False,
"executed": False,
"dry_run": bool(dry_run),
"selection_mode": "exact_pr",
"selected_pr_number": pr_number,
"reasons": [
f"pr_number={pr_number!r} is not a valid integer "
"(fail closed; no mutation)"
],
"blocker_kind": "invalid_pr_number",
}
if exact_pr <= 0:
return {
"success": False,
"performed": False,
"executed": False,
"dry_run": bool(dry_run),
"selection_mode": "exact_pr",
"selected_pr_number": exact_pr,
"reasons": [
f"pr_number={exact_pr} must be a positive integer "
"(fail closed; no mutation)"
],
"blocker_kind": "invalid_pr_number",
}
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
base = repo_api_url(h, o, r)
closed_prs = api_get_all(f"{base}/pulls?state=closed", auth, limit=limit)
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
selection_mode = "batch"
closed_prs: list[dict] = []
open_prs: list[dict] = []
if exact_pr is not None:
selection_mode = "exact_pr"
try:
pr_live = api_request("GET", f"{base}/pulls/{exact_pr}", auth)
except Exception as exc:
return {
"success": False,
"performed": False,
"executed": False,
"dry_run": bool(dry_run),
"selection_mode": selection_mode,
"selected_pr_number": exact_pr,
"reasons": [
f"PR #{exact_pr} could not be uniquely resolved "
f"(fail closed; no mutation): {_redact(str(exc))}"
],
"blocker_kind": "pr_unresolvable",
}
if not isinstance(pr_live, dict) or not pr_live:
return {
"success": False,
"performed": False,
"executed": False,
"dry_run": bool(dry_run),
"selection_mode": selection_mode,
"selected_pr_number": exact_pr,
"reasons": [
f"PR #{exact_pr} could not be uniquely resolved "
"(empty response; fail closed; no mutation)"
],
"blocker_kind": "pr_unresolvable",
}
live_number = pr_live.get("number")
try:
live_number_int = int(live_number) if live_number is not None else None
except (TypeError, ValueError):
live_number_int = None
if live_number_int != exact_pr:
return {
"success": False,
"performed": False,
"executed": False,
"dry_run": bool(dry_run),
"selection_mode": selection_mode,
"selected_pr_number": exact_pr,
"reasons": [
f"PR #{exact_pr} resolution is ambiguous or mismatched "
f"(live number={live_number!r}; fail closed; no mutation)"
],
"blocker_kind": "pr_ambiguous",
}
if not (pr_live.get("merged") or pr_live.get("merged_at")):
return {
"success": False,
"performed": False,
"executed": False,
"dry_run": bool(dry_run),
"selection_mode": selection_mode,
"selected_pr_number": exact_pr,
"reasons": [
f"PR #{exact_pr} is not merged "
"(exact-target cleanup requires a merged PR; "
"fail closed; no mutation)"
],
"blocker_kind": "pr_not_merged",
}
closed_prs = [pr_live]
# Exact mode still needs open heads for remote-delete safety gates.
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
else:
# Preserve historical call order (closed then open) for batch callers/tests.
closed_prs = api_get_all(f"{base}/pulls?state=closed", auth, limit=limit)
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
merged_closed: list[dict] = []
remote_branch_exists: dict[str, bool] = {}
@@ -11204,6 +11372,13 @@ def gitea_reconcile_merged_cleanups(
scratch_candidates = merged_cleanup_reconcile.discover_reviewer_scratch_worktrees(
_canonical_local_git_root()
)
# #855: exact-target never inventories or mutates foreign PR scratch trees.
if exact_pr is not None:
scratch_candidates = [
s
for s in scratch_candidates
if int(s.get("pr_number") or 0) == int(exact_pr)
]
active_reviewer_leases: dict[int, bool] = {}
pr_states: dict[int, dict] = {}
for scratch in scratch_candidates:
@@ -11238,6 +11413,33 @@ def gitea_reconcile_merged_cleanups(
active_reviewer_leases=active_reviewer_leases,
pr_states=pr_states,
)
report["selection_mode"] = selection_mode
if exact_pr is not None:
report["selected_pr_number"] = exact_pr
# Fail closed if exact pin somehow produced other or zero entries.
entries = list(report.get("entries") or [])
entry_numbers = []
for entry in entries:
try:
entry_numbers.append(int(entry.get("pr_number")))
except (TypeError, ValueError):
entry_numbers.append(entry.get("pr_number"))
if entry_numbers != [exact_pr]:
return {
"success": False,
"performed": False,
"executed": False,
"dry_run": bool(dry_run),
"selection_mode": selection_mode,
"selected_pr_number": exact_pr,
"reasons": [
f"exact PR #{exact_pr} selection produced unexpected "
f"candidate set {entry_numbers!r} "
"(fail closed; no mutation)"
],
"blocker_kind": "exact_selection_mismatch",
"entries": entries,
}
if dry_run:
report["dry_run"] = True