fix(gate): preserve exact-owner renewal evidence across duplicate rechecks (Closes #945)
An exact-owner lease renewal (#760) is granted the owning-PR duplicate-work
waiver inside gitea_lock_issue, but every later enforcement path rebuilt that
proof from the durable lock through
issue_lock_recovery.recovered_owning_pr_from_lock, which reads only the
dead_session_recovery block. A plain renewal persists its proof under
lease_renewal instead, so the waiver expired with the lock call and the next
author mutation was refused duplicate_commit_prevented with
owning_pr_recovery_exempted: false on the very PR the renewal had just proved.
Add issue_lock_renewal.owning_pr_renewal_from_lock as the renewal mirror of the
recovery rebuild, and resolve both halves through one helper,
_owning_pr_continuation_from_lock, in the same precedence gitea_lock_issue
applies. The three enforcement paths that previously saw only recovery evidence
now share that resolver: the commit/create-PR duplicate recheck, the read-only
duplicate assessor, and the push-ownership prover.
The rebuild re-applies the equality the renewal assessor required (PR head ==
local head == remote head) and additionally binds the record to the claimant the
lock names, so a renewal block cannot be reused under another identity, profile,
or workflow session. Validation of the resulting token against live PR state is
unchanged and still owned solely by
issue_work_duplicate_gate._assess_owning_pr_exemption, so repository, issue, PR,
branch and head binding continue to be enforced in exactly one place.
No public signature, MCP tool schema, refusal shape, or reason code changes.
Dead-session recovery behaviour is untouched.
Tests: tests/test_issue_945_owning_pr_renewal_continuation.py - 49 tests,
8 subtests, all in-memory (no durable branch, worktree, lock, lease or PR).
Against unmodified aab54d48 the suite is 47 failed / 2 passed; the 2 that pass
are the defect-pinning reproductions. Full suite 28F/5574P/6S/1002 subtests at
head vs 28F/5525P/6S/994 at base, with identical failing test id sets.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+44
-7
@@ -2781,6 +2781,36 @@ def _collect_issue_duplicate_context(
|
||||
return issue_duplicate_context_fetcher(h, o, r, auth, issue_number)
|
||||
|
||||
|
||||
def _owning_pr_continuation_from_lock(lock_record: dict | None) -> dict | None:
|
||||
"""Owning-PR continuation evidence a persisted lock still proves (#945).
|
||||
|
||||
``gitea_lock_issue`` grants the duplicate-work waiver from either a
|
||||
sanctioned dead-session recovery (#755) or a sanctioned exact-owner renewal
|
||||
(#760), in that precedence. Every later enforcement path — commit,
|
||||
create-PR, push-ownership, and the read-only duplicate assessor — re-derives
|
||||
ownership from the durable lock instead of that live assessment.
|
||||
|
||||
Until #945 only the recovery half was rebuilt there, so an ordinary
|
||||
exact-owner renewal lost its waiver the moment ``gitea_lock_issue``
|
||||
returned: the author renewed successfully and was then refused
|
||||
``duplicate_commit_prevented`` with ``owning_pr_recovery_exempted: false``
|
||||
on the very PR the renewal had just proved it owned.
|
||||
|
||||
Resolving both halves here, in the same precedence the lock path applies,
|
||||
keeps the answer from drifting between the gate that grants the waiver and
|
||||
the gates that enforce it. This only decides which server-written block the
|
||||
token is rebuilt from — the token is still re-validated against live PR
|
||||
state by ``issue_work_duplicate_gate._assess_owning_pr_exemption``, which
|
||||
remains the single authoritative policy for whether an exemption applies.
|
||||
"""
|
||||
if not lock_record:
|
||||
return None
|
||||
recovered = issue_lock_recovery.recovered_owning_pr_from_lock(lock_record)
|
||||
if recovered:
|
||||
return recovered
|
||||
return issue_lock_renewal.owning_pr_renewal_from_lock(lock_record)
|
||||
|
||||
|
||||
def _assess_issue_duplicate_gate(
|
||||
issue_number: int,
|
||||
*,
|
||||
@@ -2833,6 +2863,11 @@ def _enforce_locked_issue_duplicate_recheck(
|
||||
commit and create-PR phases run in their own calls, long after the recovery
|
||||
assessment ended, so without this they re-block the very PR the recovery
|
||||
already proved belongs to this author.
|
||||
|
||||
#945: an exact-owner *renewal* (#760) owns its open PR for exactly the same
|
||||
reason, and ``gitea_lock_issue`` already waives the blocker for both. Both
|
||||
halves are resolved together here so the renewal waiver survives past the
|
||||
lock call instead of expiring with it.
|
||||
"""
|
||||
lock_data = _load_existing_issue_lock()
|
||||
if not lock_data:
|
||||
@@ -2856,9 +2891,7 @@ def _enforce_locked_issue_duplicate_recheck(
|
||||
auth=auth,
|
||||
locked_branch=locked_branch,
|
||||
phase=phase,
|
||||
recovered_owning_pr=issue_lock_recovery.recovered_owning_pr_from_lock(
|
||||
lock_data
|
||||
),
|
||||
recovered_owning_pr=_owning_pr_continuation_from_lock(lock_data),
|
||||
)
|
||||
if gate.get("block"):
|
||||
return gate
|
||||
@@ -5140,9 +5173,10 @@ def gitea_assess_work_issue_duplicate(
|
||||
recovered_owning_pr = None
|
||||
lock_data = _load_existing_issue_lock()
|
||||
if lock_data and int(lock_data.get("issue_number") or 0) == int(issue_number):
|
||||
recovered_owning_pr = issue_lock_recovery.recovered_owning_pr_from_lock(
|
||||
lock_data
|
||||
)
|
||||
# #945: rebuilt from a sanctioned recovery *or* a sanctioned exact-owner
|
||||
# renewal, so this read-only assessor reports the same disposition the
|
||||
# commit and create-PR gates will enforce.
|
||||
recovered_owning_pr = _owning_pr_continuation_from_lock(lock_data)
|
||||
gate = _assess_issue_duplicate_gate(
|
||||
issue_number,
|
||||
h=h,
|
||||
@@ -19424,7 +19458,10 @@ def _prove_author_ownership_for_pr(
|
||||
# advance is the one recovery already sanctioned, not a foreign head.
|
||||
recovered_owning_pr = None
|
||||
if proven and lock_record:
|
||||
candidate = issue_lock_recovery.recovered_owning_pr_from_lock(lock_record)
|
||||
# #945: a sanctioned exact-owner renewal proves the same ownership of
|
||||
# the same PR, so the push gate resolves both halves rather than seeing
|
||||
# only the recovery one.
|
||||
candidate = _owning_pr_continuation_from_lock(lock_record)
|
||||
if candidate and int(candidate.get("pr_number") or 0) == int(pr_number):
|
||||
recovered_owning_pr = candidate
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user