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:
2026-07-20 02:52:29 -05:00
co-authored by Claude Opus 4.8
parent d12adabeb1
commit 5547399037
6 changed files with 971 additions and 30 deletions
+54 -1
View File
@@ -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