[bug][author-lock] Dead-session recovery deadlocks a second cross-session PR base-sync after a sanctioned server-side update_pr_branch_by_merge advanced the remote head #872

Open
opened 2026-07-24 01:33:16 -05:00 by jcwalker3 · 0 comments
Owner

Summary

gitea_lock_issue dead-session recovery (published_owning_pr mode) cannot recover an author binding after a prior sanctioned server-side base-sync (gitea_update_pr_branch_by_merge) advanced the live PR head beyond the clean local worktree head. This produces a reproducible deadlock for any PR that needs more than one base-sync across author sessions, and surfaces as an internal, non-retryable RuntimeError instead of a structured reason.

Observed live on PR #866 / issue #855 (do not modify those resources while fixing this — they are the frozen reproduction case).

Confirmed reproduction (PR #866 / #855)

  1. Author lock taken at branch head A = 24c52abf6b80e7ed562294371eee9fe8620b686e; clean registered worktree at A; durable lock records A.
  2. A sanctioned gitea_update_pr_branch_by_merge performs a server-side merge of master into the branch. The live remote PR head advances A → B = 499b87c48218cf03a3b1f34a5d8a17549d820564. The clean local worktree stays at A. The durable lock still records A. The owning PR now points at B.
  3. The author session dies.
  4. master advances again; a second base-sync is legitimately needed.
  5. The same authenticated claimant re-locks to perform the second base-sync.

Result: deadlock.

Root cause (confirmed in code)

  • gitea_mcp_server.py _evaluate_issue_lock_recovery probes head ancestry in one direction only: read_head_ancestry(ancestor_sha=remote_head, descendant_sha=local_head) — i.e. it only ever asks "does the local head descend from the remote head?" (the #768 "author committed local remediation, not yet pushed" case).
  • issue_lock_recovery.assess_dead_session_lock_recovery published-mode head agreement accepts only:
    • local_head == remote_head (#753), or
    • local_head strictly descends remote_head (#768).
  • The sanctioned base-sync case is the reverse relation: the remote head (B) strictly descends the clean local/recorded head (A). No branch handles it, so recovery returns REFUSED ("local head A does not match remote branch head B").
  • With recovery_sanctioned=False, assess_issue_lock_worktree blocks (a branch carrying committed work is never base-equivalent), and gitea_lock_issue raises RuntimeError at the base-equivalence gate — an internal, non-retryable error.
  • Sibling recoveries do not apply: dirty-rebind requires a dirty worktree (this one is clean); unpublished-branch recovery requires the remote to be an ancestor of the local head (here the relationship is reversed — the remote descends the local head).

Net: base-sync advances the remote/PR head but leaves the durable lock recorded head and the clean local worktree at the pre-sync head, and no recovery relation covers "remote legitimately advanced past local via a sanctioned merge."

Required fix

Add a third published_owning_pr head relation — remote head is a proven strict descendant of the clean local/recorded head via a sanctioned base-sync merge — and sanction dead-session recovery only when every safety fact is server-proven:

  • Same authenticated claimant and author profile; same repository, issue, PR, branch, and registered worktree.
  • Previous owning session is dead; worktree is clean; no foreign live lock/lease/session.
  • Live PR head (B) is a proven strict descendant of the recorded/local head (A) (no rewrite/force-push — all local work still reachable from B).
  • The advancement is consistent with a sanctioned PR base-sync: B is a merge commit whose first parent is exactly the clean local head A (the branch side of the merge is unchanged — no foreign commits inserted on the branch) and whose second parent descends from the current base (master).
  • The open PR head equals the advanced remote head B.

On sanction, rebind ownership so a subsequent sanctioned base-sync can run from PR head B, without discarding work, force-pushing, resetting, or rewriting history, and without mutating the local worktree except through an explicitly sanctioned, validated mechanism. Return structured reason codes on refusal instead of an internal RuntimeError. Keep every existing recovery path (first-time lock, #753/#768 dead-session recovery, dirty same-claimant rebind, unpublished-branch recovery, update-by-merge head/base race protection) unchanged.

Acceptance / tests

  • Automated regression reproducing the full two-base-sync failure (lock at A → server-side merge A→B → session death → master advances → recover same claimant → second base-sync yields head C current with master; no force-push, reset, data loss, foreign takeover, or protected-worktree mutation).
  • Negative tests: foreign claimant, live prior session, dirty worktree, diverged/rewritten (force-pushed) branch, non-merge remote advance / missing base-sync merge provenance, mismatched issue/PR/branch/repo/worktree, unrelated ancestry.
  • Deterministic and idempotent repeated recovery; authoritative post-recovery verification of the live lock and head relationships.

Boundaries

Do not manually rewrite the issue #855 lock as the solution. Do not modify PR #866, its branch, or its worktree during development. Preserve the distinction between local worktree head, recorded lock head, authoritative remote PR head, and current base/master head.

## Summary `gitea_lock_issue` dead-session recovery (`published_owning_pr` mode) cannot recover an author binding after a **prior sanctioned server-side base-sync** (`gitea_update_pr_branch_by_merge`) advanced the live PR head beyond the clean local worktree head. This produces a reproducible deadlock for any PR that needs more than one base-sync across author sessions, and surfaces as an internal, non-retryable `RuntimeError` instead of a structured reason. Observed live on PR #866 / issue #855 (do **not** modify those resources while fixing this — they are the frozen reproduction case). ## Confirmed reproduction (PR #866 / #855) 1. Author lock taken at branch head **A** = `24c52abf6b80e7ed562294371eee9fe8620b686e`; clean registered worktree at A; durable lock records A. 2. A sanctioned `gitea_update_pr_branch_by_merge` performs a **server-side merge** of master into the branch. The live remote PR head advances **A → B** = `499b87c48218cf03a3b1f34a5d8a17549d820564`. The clean local worktree stays at A. The durable lock still records A. The owning PR now points at B. 3. The author session dies. 4. master advances again; a second base-sync is legitimately needed. 5. The same authenticated claimant re-locks to perform the second base-sync. Result: deadlock. ## Root cause (confirmed in code) - `gitea_mcp_server.py` `_evaluate_issue_lock_recovery` probes head ancestry in **one direction only**: `read_head_ancestry(ancestor_sha=remote_head, descendant_sha=local_head)` — i.e. it only ever asks "does the local head *descend from* the remote head?" (the #768 "author committed local remediation, not yet pushed" case). - `issue_lock_recovery.assess_dead_session_lock_recovery` published-mode head agreement accepts only: - `local_head == remote_head` (#753), or - `local_head` strictly **descends** `remote_head` (#768). - The sanctioned base-sync case is the **reverse** relation: the **remote** head (B) strictly descends the clean **local/recorded** head (A). No branch handles it, so recovery returns `REFUSED` ("local head A does not match remote branch head B"). - With `recovery_sanctioned=False`, `assess_issue_lock_worktree` blocks (a branch carrying committed work is never base-equivalent), and `gitea_lock_issue` raises `RuntimeError` at the base-equivalence gate — an internal, non-retryable error. - Sibling recoveries do not apply: dirty-rebind requires a dirty worktree (this one is clean); unpublished-branch recovery requires the remote to be an ancestor of the local head (here the relationship is reversed — the remote descends the local head). Net: base-sync advances the remote/PR head but leaves the durable lock recorded head and the clean local worktree at the pre-sync head, and no recovery relation covers "remote legitimately advanced past local via a sanctioned merge." ## Required fix Add a third `published_owning_pr` head relation — remote head is a proven strict descendant of the clean local/recorded head **via a sanctioned base-sync merge** — and sanction dead-session recovery only when every safety fact is server-proven: - Same authenticated claimant and author profile; same repository, issue, PR, branch, and registered worktree. - Previous owning session is dead; worktree is clean; no foreign live lock/lease/session. - Live PR head (B) is a proven strict **descendant** of the recorded/local head (A) (no rewrite/force-push — all local work still reachable from B). - The advancement is consistent with a sanctioned PR base-sync: B is a **merge commit whose first parent is exactly the clean local head A** (the branch side of the merge is unchanged — no foreign commits inserted on the branch) and whose second parent descends from the current base (master). - The open PR head equals the advanced remote head B. On sanction, rebind ownership so a subsequent sanctioned base-sync can run from PR head B, without discarding work, force-pushing, resetting, or rewriting history, and without mutating the local worktree except through an explicitly sanctioned, validated mechanism. Return structured reason codes on refusal instead of an internal `RuntimeError`. Keep every existing recovery path (first-time lock, #753/#768 dead-session recovery, dirty same-claimant rebind, unpublished-branch recovery, update-by-merge head/base race protection) unchanged. ## Acceptance / tests - Automated regression reproducing the full two-base-sync failure (lock at A → server-side merge A→B → session death → master advances → recover same claimant → second base-sync yields head C current with master; no force-push, reset, data loss, foreign takeover, or protected-worktree mutation). - Negative tests: foreign claimant, live prior session, dirty worktree, diverged/rewritten (force-pushed) branch, non-merge remote advance / missing base-sync merge provenance, mismatched issue/PR/branch/repo/worktree, unrelated ancestry. - Deterministic and idempotent repeated recovery; authoritative post-recovery verification of the live lock and head relationships. ## Boundaries Do not manually rewrite the issue #855 lock as the solution. Do not modify PR #866, its branch, or its worktree during development. Preserve the distinction between local worktree head, recorded lock head, authoritative remote PR head, and current base/master head.
jcwalker3 added the mcpstatus:readyworkflow-hardeningrecoverysafetytype:bug labels 2026-07-24 01:33:16 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Scaled-Tech-Consulting/Gitea-Tools#872