fix(mcp): allow author-lock recovery after the owning session exits (Closes #753)
A durable author issue lock records the PID of the MCP session that took it.
When that process exits, assess_lock_freshness marks the lock stale (live=False)
even while its lease is still within TTL, so every ownership check that needs a
live lock fails closed -- including gitea_update_pr_branch_by_merge.
Re-taking the lock was unreachable for real work. assess_issue_lock_worktree
requires the worktree to be base-equivalent to master/main/dev, and a branch
that already carries commits is ahead of its base by construction. The existing
assess_expired_lock_reclaim affordance does not apply either: it is only
consulted once the lease has expired, so a dead PID under an unexpired lease
never reaches it. assess_own_branch_adoption already speaks of "lock recovery",
but it runs after the base-equivalence gate and so was never reached.
This adds issue_lock_recovery, a pure assessor that grants a narrow waiver only
when every element of durable ownership still matches exactly and the recorded
process is demonstrably dead: same remote/org/repo/issue, same branch (and the
worktree actually on it), same registered worktree, clean worktree, local head
== remote head == open PR head, same claimant identity/profile, no competing
live lock or lease, and no ambiguous branch claims. A malformed or incomplete
lock record can never prove ownership.
The waiver suppresses base-equivalence and nothing else. Cleanliness and every
other precondition still apply, and brand-new issue claims keep the full
requirement. A refused assessment never raises: it withholds the waiver and
lets the pre-existing guard fail closed exactly as before, so recovery can only
ever add permission, never remove a guard. Refusal reasons are appended to the
block message so a caller sees the exact missing evidence.
A completed recovery is recorded on the lock as dead_session_recovery with the
prior and replacement session PIDs, heads, and claimant, so the takeover is
auditable and never looks like an original claim. Rebinding sets the live
session PID, so the recovered lock satisfies verify_lock_for_mutation and the
downstream PR update paths.
Validation:
* new tests/test_issue_753_dead_pid_lock_recovery.py -- 33 passed
* issue-lock, adoption, store, provenance, registration, duplicate-gate,
worktree, create-issue-guard suites -- 120 passed
* MCP server, commit payloads, handoff ledger, PR ownership, branch cleanup
suites -- 286 passed
* full suite -- 3498 passed, 6 skipped, 2 failed
* the same 2 failures reproduce identically on pristine master 0425bf9a
(test_issue_702_review_findings_f1_f6 F1 recovery-before-probe and
test_reconciler_supersession_close org/repo forwarding), so they are
pre-existing and unrelated
* git diff --check clean
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01L9jMhtvTjm5EajofqqaR3F
This commit is contained in:
+98
-2
@@ -1650,6 +1650,7 @@ import issue_lock_worktree # noqa: E402
|
||||
import issue_lock_provenance # noqa: E402
|
||||
import issue_lock_store # noqa: E402
|
||||
import issue_lock_adoption # noqa: E402
|
||||
import issue_lock_recovery # noqa: E402
|
||||
import stacked_pr_support # noqa: E402
|
||||
import merge_approval_gate # noqa: E402
|
||||
import review_quarantine # noqa: E402 # #695 contaminated formal-review quarantine
|
||||
@@ -3155,8 +3156,11 @@ def gitea_lock_issue(
|
||||
worktree_path, _canonical_local_git_root()
|
||||
)
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
existing_issue_lock = _load_existing_issue_lock(
|
||||
remote=remote, org=o, repo=r, issue_number=issue_number
|
||||
)
|
||||
active_lease_block = issue_lock_store.assess_same_issue_lease_conflict(
|
||||
_load_existing_issue_lock(remote=remote, org=o, repo=r, issue_number=issue_number),
|
||||
existing_issue_lock,
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
worktree_path=resolved_worktree,
|
||||
@@ -3198,6 +3202,72 @@ def gitea_lock_issue(
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
# ── Dead-session lock recovery assessment (#753) ──
|
||||
# When the MCP session that took a lock exits, the lock goes non-live
|
||||
# (stale by dead PID) even inside its lease TTL, and the branch it owns is
|
||||
# ahead of its base by construction — so the base-equivalence gate below
|
||||
# makes normal re-lock unreachable for every PR that already exists.
|
||||
#
|
||||
# This grants a waiver ONLY for that case, proven against the durable lock
|
||||
# record plus live git/Gitea observation. A refused assessment never raises:
|
||||
# it simply withholds the waiver, leaving the pre-existing guard to fail
|
||||
# closed exactly as before. Recovery can only ever add permission.
|
||||
recovery_assessment: dict | None = None
|
||||
if (
|
||||
existing_issue_lock
|
||||
and existing_issue_lock.get("issue_number") == issue_number
|
||||
and not issue_lock_store.is_lease_live(existing_issue_lock)
|
||||
):
|
||||
recovery_auth = _auth(h)
|
||||
try:
|
||||
recovery_branches = api_get_all(
|
||||
f"{repo_api_url(h, o, r)}/branches", recovery_auth
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not list branches to verify issue-lock recovery: {exc}"
|
||||
)
|
||||
recovery_remote_head: str | None = None
|
||||
recovery_candidates: list[str] = []
|
||||
for entry in recovery_branches:
|
||||
entry_name = _branch_entry_name(entry)
|
||||
if entry_name == branch_name:
|
||||
recovery_remote_head = _branch_entry_commit_sha(entry)
|
||||
if issue_lock_adoption.branch_carries_issue_marker(entry_name, issue_number):
|
||||
recovery_candidates.append(entry_name)
|
||||
recovery_pr_head: str | None = None
|
||||
recovery_pr_number: int | None = None
|
||||
for pull in _list_open_pulls(h, o, r, recovery_auth):
|
||||
pull_head = pull.get("head") or {}
|
||||
if str(pull_head.get("ref") or "") == branch_name:
|
||||
recovery_pr_head = pull_head.get("sha")
|
||||
recovery_pr_number = pull.get("number")
|
||||
break
|
||||
recovery_claimant = _work_lease_claimant(h)
|
||||
recovery_assessment = issue_lock_recovery.assess_dead_session_lock_recovery(
|
||||
existing_issue_lock,
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
worktree_path=resolved_worktree,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
identity=recovery_claimant.get("username"),
|
||||
profile=recovery_claimant.get("profile"),
|
||||
current_branch=git_state.get("current_branch"),
|
||||
porcelain_status=git_state.get("porcelain_status") or "",
|
||||
head_sha=git_state.get("head_sha"),
|
||||
remote_head_sha=recovery_remote_head,
|
||||
pr_head_sha=recovery_pr_head,
|
||||
pr_number=recovery_pr_number,
|
||||
competing_live_locks=issue_lock_store.list_live_locks(),
|
||||
candidate_branches=recovery_candidates,
|
||||
current_pid=os.getpid(),
|
||||
)
|
||||
|
||||
recovery_sanctioned = bool(
|
||||
recovery_assessment and recovery_assessment.get("recovery_sanctioned")
|
||||
)
|
||||
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
|
||||
worktree_path=resolved_worktree,
|
||||
current_branch=git_state.get("current_branch"),
|
||||
@@ -3205,10 +3275,20 @@ def gitea_lock_issue(
|
||||
base_equivalent=git_state.get("base_equivalent"),
|
||||
inspected_git_root=git_state.get("inspected_git_root"),
|
||||
base_branch=git_state.get("base_branch"),
|
||||
recovery_sanctioned=recovery_sanctioned,
|
||||
)
|
||||
if lock_assessment["block"]:
|
||||
reasons = list(lock_assessment.get("reasons") or [])
|
||||
# Surface why recovery was unavailable, so a blocked caller sees the
|
||||
# exact missing evidence instead of only the base-equivalence text.
|
||||
if recovery_assessment and recovery_assessment.get("is_candidate"):
|
||||
reasons.append(
|
||||
issue_lock_recovery.format_recovery_refusal(recovery_assessment)
|
||||
)
|
||||
raise RuntimeError(
|
||||
issue_lock_worktree.format_issue_lock_worktree_error(lock_assessment)
|
||||
issue_lock_worktree.format_issue_lock_worktree_error(
|
||||
{**lock_assessment, "reasons": reasons}
|
||||
)
|
||||
)
|
||||
|
||||
auth = _auth(h)
|
||||
@@ -3271,6 +3351,14 @@ def gitea_lock_issue(
|
||||
}
|
||||
if stacked_approved:
|
||||
data["approved_stacked_base"] = stacked_approved
|
||||
if recovery_sanctioned and recovery_assessment:
|
||||
# #753 AC2/AC6: record that this claim was recovered after session
|
||||
# death, with the prior and replacement session identity, so the
|
||||
# takeover is auditable and never looks like an original claim.
|
||||
data["dead_session_recovery"] = issue_lock_recovery.build_recovery_record(
|
||||
recovery_assessment,
|
||||
recovered_at=_work_lease_timestamp(_work_lease_now()),
|
||||
)
|
||||
|
||||
lock_file_path = _save_issue_lock(data)
|
||||
lock_record = issue_lock_store.read_lock_file(lock_file_path) or data
|
||||
@@ -3304,6 +3392,14 @@ def gitea_lock_issue(
|
||||
"lock_freshness": freshness,
|
||||
"lock_proof": lock_proof,
|
||||
}
|
||||
if recovery_sanctioned and recovery_assessment:
|
||||
result["dead_session_recovery"] = data["dead_session_recovery"]
|
||||
result["message"] = (
|
||||
f"Recovered the durable lock for issue #{issue_number} on branch "
|
||||
f"'{branch_name}' after the owning MCP session (pid "
|
||||
f"{recovery_assessment['evidence'].get('prior_session_pid')}) exited; "
|
||||
"ownership evidence matched exactly (fail-closed check complete)."
|
||||
)
|
||||
if stacked_approved:
|
||||
result["approved_stacked_base"] = stacked_approved
|
||||
result["message"] = (
|
||||
|
||||
Reference in New Issue
Block a user