fix: head-scope durable #332 review-decision locks (#620)

Prior REQUEST_CHANGES on head A no longer blocks formal re-review on
head B of the same open PR. Locks store reviewed head SHA, hard-stop and
mark_ready/submit gates allow a fresh decision boundary when the live
head differs, and same-head duplicates remain fail-closed. Open-PR lock
cleanup stays forbidden (#594); assessment reports locked vs current
head and stale_by_head / fresh_review_on_current_head_allowed.

Closes #620
This commit is contained in:
2026-07-10 01:04:37 -04:00
parent 08c3488d7c
commit 9e1d5c5649
3 changed files with 683 additions and 46 deletions
+92 -40
View File
@@ -3095,22 +3095,26 @@ def check_review_decision_gate(
)
prior = list(lock.get("live_mutations") or [])
if prior and not lock.get("correction_authorized"):
reasons.append(
"live review mutation already recorded in this run; only one live "
"review mutation is allowed unless "
"gitea_authorize_review_correction was invoked (fail closed)"
)
elif (
action in _TERMINAL_REVIEW_ACTIONS
and any(m.get("action") in _TERMINAL_REVIEW_ACTIONS for m in prior)
and not lock.get("correction_authorized")
ready_head = lock.get("ready_expected_head_sha")
if stale_review_decision_lock.prior_live_mutations_block_boundary(
lock, pr_number=pr_number, expected_head_sha=ready_head
):
reasons.append(
"terminal review decision already submitted on this PR in this "
"run; blocked unless an operator-approved correction was "
"authorized (fail closed)"
)
if prior and not lock.get("correction_authorized"):
reasons.append(
"live review mutation already recorded for this PR head in this "
"run; only one live review mutation is allowed per head unless "
"gitea_authorize_review_correction was invoked (fail closed, #620)"
)
elif (
action in _TERMINAL_REVIEW_ACTIONS
and any(m.get("action") in _TERMINAL_REVIEW_ACTIONS for m in prior)
and not lock.get("correction_authorized")
):
reasons.append(
"terminal review decision already submitted for this PR head in "
"this run; blocked unless an operator-approved correction was "
"authorized (fail closed, #620)"
)
return reasons
@@ -3118,12 +3122,18 @@ def check_review_decision_gate(
def record_live_review_mutation(pr_number: int, action: str, review_id: int | None = None):
lock = _load_review_decision_lock() or {}
mutations = list(lock.get("live_mutations") or [])
mutations.append({
head_sha = stale_review_decision_lock.normalize_head_sha(
lock.get("ready_expected_head_sha")
)
entry = {
"pr_number": pr_number,
"action": action,
"review_id": review_id,
"review_state": action,
})
}
if head_sha:
entry["head_sha"] = head_sha
mutations.append(entry)
lock["live_mutations"] = mutations
if lock.get("correction_authorized"):
lock["correction_authorized"] = False
@@ -3131,17 +3141,28 @@ def record_live_review_mutation(pr_number: int, action: str, review_id: int | No
_save_review_decision_lock(lock)
def terminal_review_hard_stop_reasons(pr_number: int, operation: str) -> list[str]:
"""Session hard-stop after a terminal live review mutation (#332).
def terminal_review_hard_stop_reasons(
pr_number: int,
operation: str,
expected_head_sha: str | None = None,
) -> list[str]:
"""Session hard-stop after a terminal live review mutation (#332 / #620).
After a terminal verdict is consumed in this run, the only permitted
continuation is the merge sequence for the same PR that was approved.
A REQUEST_CHANGES (or an approval of a different PR) blocks every
further review/mark-ready/merge mutation. An operator-approved
correction (#211) re-opens the review path only — never a cross-PR
merge.
After a terminal verdict is consumed for a given PR **head**, the only
permitted continuation is the merge sequence for that same approved PR
(merge does not require a new head). A REQUEST_CHANGES (or an approval of
a different PR, or the same head) blocks further review/mark-ready/merge
mutations for that boundary.
*operation* is one of 'merge', 'mark_ready', or 'review'.
#620: when *expected_head_sha* differs from the last terminal's reviewed
head on the **same open PR**, mark_ready / review / resume may proceed so
a fresh formal decision can be recorded for the new head. Historical
mutations are preserved.
An operator-approved correction (#211) re-opens the review path only —
never a cross-PR merge.
*operation* is one of 'merge', 'mark_ready', 'review', or 'resume'.
Returns [] when the operation may proceed.
"""
lock = _load_review_decision_lock()
@@ -3160,8 +3181,19 @@ def terminal_review_hard_stop_reasons(pr_number: int, operation: str) -> list[st
and last.get("pr_number") == pr_number
):
return []
if operation in ("mark_ready", "review") and lock.get("correction_authorized"):
if operation in ("mark_ready", "review", "resume") and lock.get(
"correction_authorized"
):
return []
# #620: same PR, different reviewed head → fresh decision boundary.
if stale_review_decision_lock.terminal_boundary_allows_fresh_decision(
lock,
pr_number=pr_number,
expected_head_sha=expected_head_sha,
operation=operation,
):
return []
locked_head = stale_review_decision_lock.mutation_head_sha(last, lock)
if last.get("action") == "approve":
guidance = (
f"only the merge sequence for approved PR "
@@ -3169,15 +3201,22 @@ def terminal_review_hard_stop_reasons(pr_number: int, operation: str) -> list[st
)
else:
guidance = "the session must stop and produce a final report"
head_note = (
f" at head {locked_head[:12]}"
if locked_head
else ""
)
recovery = (
"if that PR is already merged/closed, use "
"gitea_cleanup_stale_review_decision_lock (apply=true) after live-state "
"proof (#594); never delete session-state files by hand"
"proof (#594); if the PR is still open but the head moved, mark/submit "
"with the new expected_head_sha (#620); never delete session-state "
"files by hand"
)
return [
"terminal review mutation already consumed in this run "
f"({last.get('action')} on PR #{last.get('pr_number')}); {guidance} "
f"(fail closed, #332); {recovery}"
f"({last.get('action')} on PR #{last.get('pr_number')}{head_note}); "
f"{guidance} (fail closed, #332); {recovery}"
]
@@ -3800,18 +3839,11 @@ def gitea_mark_final_review_decision(
"reasons": workflow_blockers + (
review_workflow_load.recovery_handoff_without_replay()),
}
hard_stop = terminal_review_hard_stop_reasons(pr_number, "mark_ready")
hard_stop = terminal_review_hard_stop_reasons(
pr_number, "mark_ready", expected_head_sha=expected_head_sha
)
if hard_stop:
return {"marked_ready": False, "reasons": hard_stop}
if lock.get("live_mutations") and not lock.get("correction_authorized"):
return {
"marked_ready": False,
"reasons": [
"cannot mark final decision after a live review mutation was "
"already recorded in this run unless an operator-approved "
"correction was authorized"
],
}
if action not in _REVIEW_ACTIONS:
return {
"marked_ready": False,
@@ -3828,6 +3860,17 @@ def gitea_mark_final_review_decision(
"decision (fail closed, #399)"
],
}
if stale_review_decision_lock.prior_live_mutations_block_boundary(
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
):
return {
"marked_ready": False,
"reasons": [
"cannot mark final decision after a live review mutation was "
"already recorded for this PR head unless an operator-approved "
"correction was authorized (fail closed, #620)"
],
}
elig = gitea_check_pr_eligibility(
pr_number=pr_number,
action="review",
@@ -3880,6 +3923,8 @@ def gitea_mark_final_review_decision(
"#332)"
],
}
# Preserve historical terminal head boundaries before advancing ready_* (#620).
stale_review_decision_lock.backfill_terminal_heads_from_ready(lock)
lock["final_review_decision_ready"] = True
lock["ready_pr_number"] = pr_number
lock["ready_action"] = action
@@ -3897,6 +3942,7 @@ def gitea_mark_final_review_decision(
"org": org,
"repo": repo,
"final_review_decision_ready": True,
"head_scoped": True,
"reasons": [],
}
@@ -4063,6 +4109,12 @@ def gitea_cleanup_stale_review_decision_lock(
"cleanup_allowed": assessment.get("cleanup_allowed"),
"last_terminal_pr": assessment.get("last_terminal_pr"),
"last_terminal_action": assessment.get("last_terminal_action"),
"locked_head_sha": assessment.get("locked_head_sha"),
"current_pr_head_sha": assessment.get("current_pr_head_sha"),
"stale_by_head": assessment.get("stale_by_head"),
"fresh_review_on_current_head_allowed": assessment.get(
"fresh_review_on_current_head_allowed"
),
"pr_state": assessment.get("pr_state"),
"pr_merged": assessment.get("pr_merged"),
"pr_merged_or_closed": assessment.get("pr_merged_or_closed"),