fix(mcp): accept strict-descendant dead-session recovery (Closes #768)
Permit fail-closed recovery when a clean local head is a strict descendant of the recorded PR/remote head, with server-side ancestry proof. Propagate recovery evidence through commit, push, and PR duplicate gates so an owning PR is not re-blocked as competing work. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+54
-1
@@ -2273,7 +2273,14 @@ def _enforce_locked_issue_duplicate_recheck(
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict | None:
|
||||
"""Re-check duplicate-work gates for the locked issue (#400)."""
|
||||
"""Re-check duplicate-work gates for the locked issue (#400).
|
||||
|
||||
#768 AC2: when the lock being re-checked carries a sanctioned dead-session
|
||||
recovery, carry that server-derived owning-PR evidence into the gate. The
|
||||
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.
|
||||
"""
|
||||
lock_data = _load_existing_issue_lock()
|
||||
if not lock_data:
|
||||
return None
|
||||
@@ -2296,6 +2303,9 @@ 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
|
||||
),
|
||||
)
|
||||
if gate.get("block"):
|
||||
return gate
|
||||
@@ -3402,6 +3412,24 @@ def gitea_lock_issue(
|
||||
recovery_pr_number = pull.get("number")
|
||||
break
|
||||
recovery_claimant = _work_lease_claimant(h)
|
||||
# #768: the recovering author's clean worktree is, by construction, one
|
||||
# commit ahead of the head recorded at lock time — committing is the
|
||||
# only sanctioned way to clean it without discarding the work. Observe
|
||||
# the ancestry here, server-side, so the assessor can tell an honest
|
||||
# fast-forward from a rewritten head. Probed only when the heads
|
||||
# actually differ, and never from any caller-supplied value.
|
||||
recovery_local_head = git_state.get("head_sha")
|
||||
recovery_ancestry: dict | None = None
|
||||
if (
|
||||
recovery_remote_head
|
||||
and recovery_local_head
|
||||
and recovery_remote_head != recovery_local_head
|
||||
):
|
||||
recovery_ancestry = issue_lock_worktree.read_head_ancestry(
|
||||
resolved_worktree,
|
||||
ancestor_sha=recovery_remote_head,
|
||||
descendant_sha=recovery_local_head,
|
||||
)
|
||||
recovery_assessment = issue_lock_recovery.assess_dead_session_lock_recovery(
|
||||
existing_issue_lock,
|
||||
issue_number=issue_number,
|
||||
@@ -3421,6 +3449,7 @@ def gitea_lock_issue(
|
||||
competing_live_locks=issue_lock_store.list_live_locks(),
|
||||
candidate_branches=recovery_candidates,
|
||||
current_pid=os.getpid(),
|
||||
head_ancestry=recovery_ancestry,
|
||||
)
|
||||
|
||||
recovery_sanctioned = bool(
|
||||
@@ -3628,6 +3657,15 @@ def gitea_assess_work_issue_duplicate(
|
||||
}
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
# #768 AC2: recovery evidence comes only from the durable lock this session
|
||||
# already holds, and only when that lock is for the issue being assessed.
|
||||
# Nothing here is reachable through this tool's parameters.
|
||||
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
|
||||
)
|
||||
gate = _assess_issue_duplicate_gate(
|
||||
issue_number,
|
||||
h=h,
|
||||
@@ -3636,6 +3674,7 @@ def gitea_assess_work_issue_duplicate(
|
||||
auth=auth,
|
||||
locked_branch=branch_name,
|
||||
phase=phase,
|
||||
recovered_owning_pr=recovered_owning_pr,
|
||||
)
|
||||
return {"success": not gate.get("block"), **gate}
|
||||
|
||||
@@ -16115,6 +16154,16 @@ def _prove_author_ownership_for_pr(
|
||||
"durable lock, or branch lock (fail closed; issue_number need not "
|
||||
"equal pr_number when the tracking issue is linked)"
|
||||
)
|
||||
# #768 AC2: when the lock that proved ownership was itself a sanctioned
|
||||
# dead-session recovery, carry that server-derived evidence into the push
|
||||
# gate rather than making it re-derive ownership blind. ``recorded_head``
|
||||
# and ``accepted_head`` let the caller see that the PR head it is about to
|
||||
# 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)
|
||||
if candidate and int(candidate.get("pr_number") or 0) == int(pr_number):
|
||||
recovered_owning_pr = candidate
|
||||
return {
|
||||
"proven": proven,
|
||||
"has_author_lock": proven,
|
||||
@@ -16123,6 +16172,7 @@ def _prove_author_ownership_for_pr(
|
||||
"matched_issue": matched_issue,
|
||||
"matched_via": matched_via,
|
||||
"lock_record": lock_record,
|
||||
"recovered_owning_pr": recovered_owning_pr,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
@@ -16585,6 +16635,9 @@ def gitea_update_pr_branch_by_merge(
|
||||
"linked_issues": ownership.get("linked_issues"),
|
||||
"matched_issue": ownership.get("matched_issue"),
|
||||
"matched_via": ownership.get("matched_via"),
|
||||
# #768 AC2: present only when a sanctioned dead-session recovery already
|
||||
# proved this exact PR belongs to this author.
|
||||
"recovered_owning_pr": ownership.get("recovered_owning_pr"),
|
||||
}
|
||||
preflight["host"] = h
|
||||
preflight["org"] = o
|
||||
|
||||
Reference in New Issue
Block a user