#753 added the dead-session author-lock recovery assessor, but no sanctioned
recovery could ever reach the lock write. A dead-session lock is by construction
a lock for work that already has an open PR, and the #400 duplicate-work gate
blocked unconditionally on any linked open PR. gitea_lock_issue computed
recovery_sanctioned, then discarded it one gate later:
open PR #750 already covers issue #749 (fail closed)
Because the recovered lock was never persisted, it stayed non-live, so
_prove_author_ownership_for_pr found no live author lock and
gitea_update_pr_branch_by_merge failed closed too. Both blockers had a single
cause, so only the first one is fixed here.
issue_lock_recovery.owning_pr_recovery_evidence distils a granted assessment
into the minimum evidence the duplicate gate needs: issue, branch, owning PR
number, and head. It returns None unless the outcome is RECOVERY_SANCTIONED and
the assessment's own evidence agrees that local head == remote head == PR head,
so a partial or hand-built assessment cannot authorize anything.
The duplicate gate takes that evidence and re-checks every element against the
live PR list it was handed: exactly one linked open PR, matching number, head
branch, head SHA, and locked branch. Only that exact self-owned PR is exempt.
A different PR, several linked PRs, a different branch or head, or evidence for
another issue all keep failing closed, with a diagnostic naming which element
disagreed. The competing-branch, claim-lease and commit/push/create_pr arms are
untouched, and with no evidence the gate behaves exactly as before.
Ownership proof needed no change: once the recovered lock persists under the
live session pid, _prove_author_ownership_for_pr matches it as before.
Out of scope: the PHASE_COMMIT/PHASE_PUSH/PHASE_CREATE_PR rechecks still block
on a linked open PR for every author, recovered or not. That is pre-existing
#400 behavior, unrelated to lock recovery, and #755 does not cover it.
Tests
- tests/test_issue_755_owning_pr_recovery.py (new, 31 tests) drives the real
mcp_server.gitea_lock_issue handler, not only the pure assessor: sanctioned
recovery relocks, persists a live lease with truthful dead_session_recovery
provenance, and satisfies _prove_author_ownership_for_pr; competing PR,
multiple linked PRs, mismatched head/branch/worktree, dirty worktree, live
prior pid, and a fresh claim with no prior lock all stay blocked.
- Neutralizing owning_pr_recovery_evidence regresses all three success tests to
the exact production error above, so the coverage is load-bearing.
- Focused suites (755, 753, duplicate gates, lock registration, provenance) —
102 passed.
- Lock/ownership/worktree/handoff suites — 172 passed, 16 subtests.
- Full suite tests/ — 3529 passed, 6 skipped, 2 failed, 365 subtests.
- The same 2 failures reproduce on a pristine detached worktree at
08f67007c5 (3498 passed, 6 skipped, 2 failed):
test_issue_702_review_findings_f1_f6 F1 recovery-before-probe and
test_reconciler_supersession_close org/repo forwarding. Pre-existing.
- git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_017BNsk3KUuFchaZyPJsCxjk
610 lines
22 KiB
Python
610 lines
22 KiB
Python
import sys as _sys
|
|
from pathlib import Path as _Path
|
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
|
"""Dead-session lock recovery when the issue already owns an open PR (#755).
|
|
|
|
#753 added the recovery *assessor*, but the production ``gitea_lock_issue``
|
|
path still rejected every sanctioned recovery: a dead-session lock is by
|
|
construction a lock for work that already has an open PR, and the #400
|
|
duplicate-work gate blocked unconditionally on any linked open PR. These tests
|
|
drive the real MCP handler, not just the pure assessor, so that gap cannot
|
|
reopen.
|
|
"""
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
import issue_lock_provenance # noqa: E402
|
|
import issue_lock_recovery # noqa: E402
|
|
import issue_lock_store # noqa: E402
|
|
import mcp_server # noqa: E402
|
|
from issue_work_duplicate_gate import ( # noqa: E402
|
|
OUTCOME_DUPLICATE_PR_PREVENTED,
|
|
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED,
|
|
PHASE_LOCK,
|
|
assess_work_issue_duplicate_gate,
|
|
)
|
|
|
|
ISSUE = 4755
|
|
BRANCH = f"fix/issue-{ISSUE}-owning-pr"
|
|
OTHER_BRANCH = f"fix/issue-{ISSUE}-competing"
|
|
HEAD = "c" * 40
|
|
OTHER_HEAD = "d" * 40
|
|
OWNING_PR = 4756
|
|
OTHER_PR = 4757
|
|
IDENTITY = "example-user"
|
|
PROFILE = "test-author-prgs"
|
|
ORG = "Scaled-Tech-Consulting"
|
|
REPO = "Gitea-Tools"
|
|
|
|
|
|
def dead_pid() -> int:
|
|
"""A PID that has certainly exited (spawned, then reaped)."""
|
|
proc = subprocess.Popen([sys.executable, "-c", "pass"])
|
|
proc.wait()
|
|
return proc.pid
|
|
|
|
|
|
def shifted_ts(hours: int = 4) -> str:
|
|
return (
|
|
(datetime.now(timezone.utc) + timedelta(hours=hours))
|
|
.isoformat()
|
|
.replace("+00:00", "Z")
|
|
)
|
|
|
|
|
|
def owning_pr(number=OWNING_PR, ref=BRANCH, sha=HEAD, issue=ISSUE):
|
|
return {
|
|
"number": number,
|
|
"title": f"fix: something (Closes #{issue})",
|
|
"body": f"Closes #{issue}.",
|
|
"head": {"ref": ref, "sha": sha},
|
|
}
|
|
|
|
|
|
def sanctioned_token(
|
|
issue_number=ISSUE, pr_number=OWNING_PR, branch=BRANCH, head=HEAD
|
|
):
|
|
"""The evidence shape the server derives from a granted recovery."""
|
|
return {
|
|
"issue_number": issue_number,
|
|
"pr_number": pr_number,
|
|
"branch_name": branch,
|
|
"head_sha": head,
|
|
}
|
|
|
|
|
|
# ───────────────────────── duplicate gate: exemption ─────────────────────────
|
|
|
|
|
|
class TestOwningPrExemptionGranted(unittest.TestCase):
|
|
def test_exact_owning_pr_is_not_duplicate_work(self):
|
|
result = assess_work_issue_duplicate_gate(
|
|
ISSUE,
|
|
open_prs=[owning_pr()],
|
|
branch_names=[BRANCH],
|
|
claim_entry={"status": "not_claimed"},
|
|
locked_branch=BRANCH,
|
|
phase=PHASE_LOCK,
|
|
recovered_owning_pr=sanctioned_token(),
|
|
)
|
|
self.assertFalse(result["block"])
|
|
self.assertTrue(result["owning_pr_recovery_exempted"])
|
|
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
|
|
self.assertEqual(result["linked_open_pr"], OWNING_PR)
|
|
self.assertEqual(result["linked_open_pr_count"], 1)
|
|
|
|
def test_unrelated_open_pr_alongside_owning_pr_is_ignored(self):
|
|
unrelated = {
|
|
"number": 999,
|
|
"title": "chore: unrelated",
|
|
"body": "no linkage",
|
|
"head": {"ref": "chore/unrelated", "sha": OTHER_HEAD},
|
|
}
|
|
result = assess_work_issue_duplicate_gate(
|
|
ISSUE,
|
|
open_prs=[unrelated, owning_pr()],
|
|
branch_names=[BRANCH],
|
|
claim_entry={"status": "not_claimed"},
|
|
locked_branch=BRANCH,
|
|
phase=PHASE_LOCK,
|
|
recovered_owning_pr=sanctioned_token(),
|
|
)
|
|
self.assertFalse(result["block"])
|
|
self.assertTrue(result["owning_pr_recovery_exempted"])
|
|
self.assertEqual(result["linked_open_pr_count"], 1)
|
|
|
|
|
|
class TestOwningPrExemptionRefused(unittest.TestCase):
|
|
def assert_blocked(self, result):
|
|
self.assertTrue(result["block"])
|
|
self.assertFalse(result["owning_pr_recovery_exempted"])
|
|
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_PR_PREVENTED)
|
|
|
|
def test_no_recovery_evidence_keeps_ordinary_blocker(self):
|
|
self.assert_blocked(
|
|
assess_work_issue_duplicate_gate(
|
|
ISSUE,
|
|
open_prs=[owning_pr()],
|
|
branch_names=[BRANCH],
|
|
claim_entry={"status": "not_claimed"},
|
|
locked_branch=BRANCH,
|
|
phase=PHASE_LOCK,
|
|
)
|
|
)
|
|
|
|
def test_competing_pr_number_refused(self):
|
|
result = assess_work_issue_duplicate_gate(
|
|
ISSUE,
|
|
open_prs=[owning_pr(number=OTHER_PR)],
|
|
branch_names=[BRANCH],
|
|
claim_entry={"status": "not_claimed"},
|
|
locked_branch=BRANCH,
|
|
phase=PHASE_LOCK,
|
|
recovered_owning_pr=sanctioned_token(),
|
|
)
|
|
self.assert_blocked(result)
|
|
|
|
def test_multiple_linked_open_prs_refused(self):
|
|
result = assess_work_issue_duplicate_gate(
|
|
ISSUE,
|
|
open_prs=[owning_pr(), owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
|
|
branch_names=[BRANCH],
|
|
claim_entry={"status": "not_claimed"},
|
|
locked_branch=BRANCH,
|
|
phase=PHASE_LOCK,
|
|
recovered_owning_pr=sanctioned_token(),
|
|
)
|
|
self.assert_blocked(result)
|
|
self.assertEqual(result["linked_open_pr_count"], 2)
|
|
|
|
def test_different_branch_refused(self):
|
|
result = assess_work_issue_duplicate_gate(
|
|
ISSUE,
|
|
open_prs=[owning_pr(ref=OTHER_BRANCH)],
|
|
branch_names=[BRANCH],
|
|
claim_entry={"status": "not_claimed"},
|
|
locked_branch=BRANCH,
|
|
phase=PHASE_LOCK,
|
|
recovered_owning_pr=sanctioned_token(),
|
|
)
|
|
self.assert_blocked(result)
|
|
|
|
def test_locked_branch_differing_from_evidence_refused(self):
|
|
result = assess_work_issue_duplicate_gate(
|
|
ISSUE,
|
|
open_prs=[owning_pr()],
|
|
branch_names=[BRANCH],
|
|
claim_entry={"status": "not_claimed"},
|
|
locked_branch=OTHER_BRANCH,
|
|
phase=PHASE_LOCK,
|
|
recovered_owning_pr=sanctioned_token(),
|
|
)
|
|
self.assert_blocked(result)
|
|
|
|
def test_different_head_refused(self):
|
|
result = assess_work_issue_duplicate_gate(
|
|
ISSUE,
|
|
open_prs=[owning_pr(sha=OTHER_HEAD)],
|
|
branch_names=[BRANCH],
|
|
claim_entry={"status": "not_claimed"},
|
|
locked_branch=BRANCH,
|
|
phase=PHASE_LOCK,
|
|
recovered_owning_pr=sanctioned_token(),
|
|
)
|
|
self.assert_blocked(result)
|
|
|
|
def test_evidence_for_another_issue_refused(self):
|
|
result = assess_work_issue_duplicate_gate(
|
|
ISSUE,
|
|
open_prs=[owning_pr()],
|
|
branch_names=[BRANCH],
|
|
claim_entry={"status": "not_claimed"},
|
|
locked_branch=BRANCH,
|
|
phase=PHASE_LOCK,
|
|
recovered_owning_pr=sanctioned_token(issue_number=ISSUE + 1),
|
|
)
|
|
self.assert_blocked(result)
|
|
|
|
def test_missing_head_in_live_pr_refused(self):
|
|
pr = owning_pr()
|
|
pr["head"] = {"ref": BRANCH}
|
|
result = assess_work_issue_duplicate_gate(
|
|
ISSUE,
|
|
open_prs=[pr],
|
|
branch_names=[BRANCH],
|
|
claim_entry={"status": "not_claimed"},
|
|
locked_branch=BRANCH,
|
|
phase=PHASE_LOCK,
|
|
recovered_owning_pr=sanctioned_token(),
|
|
)
|
|
self.assert_blocked(result)
|
|
|
|
|
|
class TestOrdinaryDuplicateBehaviorUnchanged(unittest.TestCase):
|
|
def test_clean_issue_still_passes(self):
|
|
result = assess_work_issue_duplicate_gate(
|
|
ISSUE,
|
|
open_prs=[],
|
|
branch_names=["feat/other-issue-99"],
|
|
claim_entry={"status": "not_claimed"},
|
|
locked_branch=BRANCH,
|
|
phase=PHASE_LOCK,
|
|
)
|
|
self.assertFalse(result["block"])
|
|
self.assertFalse(result["owning_pr_recovery_exempted"])
|
|
|
|
def test_competing_branch_still_blocks_even_with_owning_pr_evidence(self):
|
|
result = assess_work_issue_duplicate_gate(
|
|
ISSUE,
|
|
open_prs=[owning_pr()],
|
|
branch_names=[BRANCH, OTHER_BRANCH],
|
|
claim_entry={"status": "not_claimed"},
|
|
locked_branch=BRANCH,
|
|
phase=PHASE_LOCK,
|
|
recovered_owning_pr=sanctioned_token(),
|
|
)
|
|
# The owning PR is exempt, but the competing branch is not.
|
|
self.assertTrue(result["block"])
|
|
self.assertTrue(result["owning_pr_recovery_exempted"])
|
|
self.assertIn(OTHER_BRANCH, result["conflicting_branches"])
|
|
|
|
|
|
# ─────────────────── server-derived evidence cannot be forged ───────────────────
|
|
|
|
|
|
class TestOwningPrEvidenceDerivation(unittest.TestCase):
|
|
def granted(self, **evidence_overrides):
|
|
evidence = {
|
|
"issue_number": ISSUE,
|
|
"locked_branch": BRANCH,
|
|
"local_head": HEAD,
|
|
"remote_head": HEAD,
|
|
"pr_head": HEAD,
|
|
"pr_number": OWNING_PR,
|
|
}
|
|
evidence.update(evidence_overrides)
|
|
return {
|
|
"outcome": issue_lock_recovery.RECOVERY_SANCTIONED,
|
|
"recovery_sanctioned": True,
|
|
"is_candidate": True,
|
|
"reasons": [],
|
|
"evidence": evidence,
|
|
}
|
|
|
|
def test_granted_recovery_yields_evidence(self):
|
|
token = issue_lock_recovery.owning_pr_recovery_evidence(self.granted())
|
|
self.assertEqual(token, sanctioned_token())
|
|
|
|
def test_none_assessment_yields_nothing(self):
|
|
self.assertIsNone(issue_lock_recovery.owning_pr_recovery_evidence(None))
|
|
|
|
def test_refused_assessment_yields_nothing(self):
|
|
refused = self.granted()
|
|
refused["outcome"] = issue_lock_recovery.REFUSED
|
|
refused["recovery_sanctioned"] = False
|
|
self.assertIsNone(issue_lock_recovery.owning_pr_recovery_evidence(refused))
|
|
|
|
def test_sanctioned_flag_without_outcome_yields_nothing(self):
|
|
forged = self.granted()
|
|
forged["outcome"] = "SOMETHING_ELSE"
|
|
self.assertIsNone(issue_lock_recovery.owning_pr_recovery_evidence(forged))
|
|
|
|
def test_missing_pr_number_yields_nothing(self):
|
|
self.assertIsNone(
|
|
issue_lock_recovery.owning_pr_recovery_evidence(
|
|
self.granted(pr_number=None)
|
|
)
|
|
)
|
|
|
|
def test_head_disagreement_in_evidence_yields_nothing(self):
|
|
self.assertIsNone(
|
|
issue_lock_recovery.owning_pr_recovery_evidence(
|
|
self.granted(remote_head=OTHER_HEAD)
|
|
)
|
|
)
|
|
self.assertIsNone(
|
|
issue_lock_recovery.owning_pr_recovery_evidence(
|
|
self.granted(local_head=OTHER_HEAD)
|
|
)
|
|
)
|
|
|
|
def test_real_refused_assessment_yields_nothing(self):
|
|
"""End-to-end against the real assessor, not a hand-built dict."""
|
|
lock = {
|
|
"issue_number": ISSUE,
|
|
"branch_name": BRANCH,
|
|
"worktree_path": "/scratch/wt",
|
|
"remote": "prgs",
|
|
"org": "Example-Org",
|
|
"repo": "Example-Repo",
|
|
"session_pid": os.getpid(), # alive → must refuse
|
|
"work_lease": {
|
|
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
|
"issue_number": ISSUE,
|
|
"branch": BRANCH,
|
|
"worktree_path": "/scratch/wt",
|
|
"claimant": {"username": IDENTITY, "profile": PROFILE},
|
|
"expires_at": shifted_ts(),
|
|
},
|
|
}
|
|
assessment = issue_lock_recovery.assess_dead_session_lock_recovery(
|
|
lock,
|
|
issue_number=ISSUE,
|
|
branch_name=BRANCH,
|
|
worktree_path="/scratch/wt",
|
|
remote="prgs",
|
|
org="Example-Org",
|
|
repo="Example-Repo",
|
|
identity=IDENTITY,
|
|
profile=PROFILE,
|
|
current_branch=BRANCH,
|
|
porcelain_status="",
|
|
head_sha=HEAD,
|
|
remote_head_sha=HEAD,
|
|
pr_head_sha=HEAD,
|
|
pr_number=OWNING_PR,
|
|
competing_live_locks=[],
|
|
candidate_branches=[BRANCH],
|
|
current_pid=os.getpid(),
|
|
)
|
|
self.assertFalse(assessment["recovery_sanctioned"])
|
|
self.assertIsNone(
|
|
issue_lock_recovery.owning_pr_recovery_evidence(assessment)
|
|
)
|
|
|
|
|
|
# ──────────────────── end-to-end: the real gitea_lock_issue ────────────────────
|
|
|
|
|
|
class LockIssueEndToEndBase(unittest.TestCase):
|
|
"""Drives ``mcp_server.gitea_lock_issue`` with live git/Gitea observation
|
|
stubbed at the module boundary — the production gate chain itself runs."""
|
|
|
|
def setUp(self):
|
|
self.lock_dir = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.lock_dir.cleanup)
|
|
self.worktree = os.path.realpath(os.getcwd())
|
|
# Bind host/org/repo to what the ``test-author-prgs`` fixture profile is
|
|
# pinned to, so the session-context gate under test is the real one and
|
|
# not a cross-host denial. The issue number and lock dir stay synthetic.
|
|
self.remotes = patch.dict(mcp_server.REMOTES, {
|
|
"prgs": {
|
|
"host": "gitea.prgs.cc",
|
|
"org": ORG,
|
|
"repo": REPO,
|
|
},
|
|
})
|
|
self.remotes.start()
|
|
self.addCleanup(patch.stopall)
|
|
mcp_server._IDENTITY_CACHE.clear()
|
|
|
|
def write_durable_lock(self, *, pid, branch=BRANCH, worktree=None):
|
|
path = issue_lock_store.lock_file_path(
|
|
remote="prgs",
|
|
org=ORG,
|
|
repo=REPO,
|
|
issue_number=ISSUE,
|
|
lock_dir=self.lock_dir.name,
|
|
)
|
|
claimant = {"username": IDENTITY, "profile": PROFILE}
|
|
data = {
|
|
"issue_number": ISSUE,
|
|
"branch_name": branch,
|
|
"remote": "prgs",
|
|
"org": ORG,
|
|
"repo": REPO,
|
|
"worktree_path": worktree or self.worktree,
|
|
"session_pid": pid,
|
|
"pid": pid,
|
|
"work_lease": {
|
|
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
|
"issue_number": ISSUE,
|
|
"branch": branch,
|
|
"worktree_path": worktree or self.worktree,
|
|
"claimant": claimant,
|
|
"created_at": shifted_ts(-1),
|
|
"last_heartbeat_at": shifted_ts(-1),
|
|
"expires_at": shifted_ts(),
|
|
},
|
|
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
|
tool="gitea_lock_issue",
|
|
claimant=claimant,
|
|
),
|
|
}
|
|
issue_lock_store.save_lock_file(path, data)
|
|
return path
|
|
|
|
def run_lock(
|
|
self,
|
|
*,
|
|
open_prs,
|
|
porcelain="",
|
|
current_branch=BRANCH,
|
|
base_equivalent=False,
|
|
branch_names=None,
|
|
head_sha=HEAD,
|
|
remote_head=HEAD,
|
|
):
|
|
branch_names = branch_names if branch_names is not None else [BRANCH]
|
|
branch_entries = [
|
|
{"name": name, "commit": {"id": remote_head}} for name in branch_names
|
|
]
|
|
env = shared_mutation_env(
|
|
"test-author-prgs",
|
|
include_example_repo=True,
|
|
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
|
|
)
|
|
with patch(
|
|
"mcp_server.api_get_all", return_value=branch_entries
|
|
), patch(
|
|
"mcp_server._list_open_pulls", return_value=list(open_prs)
|
|
), patch(
|
|
"mcp_server.get_auth_header", return_value="token x"
|
|
), patch(
|
|
"mcp_server._work_lease_claimant",
|
|
return_value={"username": IDENTITY, "profile": PROFILE},
|
|
), patch(
|
|
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
|
return_value={
|
|
"current_branch": current_branch,
|
|
"porcelain_status": porcelain,
|
|
"base_equivalent": base_equivalent,
|
|
"head_sha": head_sha,
|
|
"inspected_git_root": self.worktree,
|
|
"base_branch": "master",
|
|
},
|
|
), patch(
|
|
"mcp_server.issue_duplicate_context_fetcher",
|
|
side_effect=lambda h, o, r, auth, issue_number: (
|
|
list(open_prs), list(branch_names), {"status": "not_claimed"}
|
|
),
|
|
):
|
|
with patch.dict(os.environ, env, clear=True):
|
|
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
|
return mcp_server.gitea_lock_issue(
|
|
issue_number=ISSUE,
|
|
branch_name=BRANCH,
|
|
remote="prgs",
|
|
worktree_path=self.worktree,
|
|
)
|
|
|
|
|
|
class TestRecoveryWithOwningPrSucceeds(LockIssueEndToEndBase):
|
|
def test_dead_session_recovery_with_owning_pr_relocks(self):
|
|
self.write_durable_lock(pid=dead_pid())
|
|
result = self.run_lock(open_prs=[owning_pr()])
|
|
|
|
self.assertTrue(result["success"])
|
|
self.assertEqual(result["issue_number"], ISSUE)
|
|
self.assertEqual(result["branch_name"], BRANCH)
|
|
self.assertTrue(result["lock_freshness"]["live"])
|
|
self.assertTrue(result["lock_freshness"]["pid_alive"])
|
|
|
|
def test_recovered_lock_records_truthful_provenance(self):
|
|
prior = dead_pid()
|
|
self.write_durable_lock(pid=prior)
|
|
self.run_lock(open_prs=[owning_pr()])
|
|
|
|
lock = issue_lock_store.load_issue_lock(
|
|
remote="prgs",
|
|
org=ORG,
|
|
repo=REPO,
|
|
issue_number=ISSUE,
|
|
lock_dir=self.lock_dir.name,
|
|
)
|
|
record = lock.get("dead_session_recovery") or {}
|
|
self.assertTrue(record.get("recovered"))
|
|
self.assertEqual(record.get("prior_session_pid"), prior)
|
|
self.assertEqual(record.get("replacement_session_pid"), os.getpid())
|
|
self.assertFalse(record.get("prior_pid_alive"))
|
|
self.assertEqual(record.get("pr_number"), OWNING_PR)
|
|
self.assertEqual(record.get("branch_name"), BRANCH)
|
|
self.assertEqual(record.get("identity"), IDENTITY)
|
|
|
|
def test_recovered_lock_is_live_and_proves_pr_ownership(self):
|
|
"""AC6: the persisted lock satisfies update-by-merge's ownership prover."""
|
|
self.write_durable_lock(pid=dead_pid())
|
|
self.run_lock(open_prs=[owning_pr()])
|
|
|
|
lock = issue_lock_store.load_issue_lock(
|
|
remote="prgs",
|
|
org=ORG,
|
|
repo=REPO,
|
|
issue_number=ISSUE,
|
|
lock_dir=self.lock_dir.name,
|
|
)
|
|
self.assertTrue(issue_lock_store.is_lease_live(lock))
|
|
|
|
env = shared_mutation_env(
|
|
"test-author-prgs",
|
|
include_example_repo=True,
|
|
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
|
|
)
|
|
with patch.dict(os.environ, env, clear=True):
|
|
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
|
ownership = mcp_server._prove_author_ownership_for_pr(
|
|
pr_number=OWNING_PR,
|
|
pr_title=f"fix: something (Closes #{ISSUE})",
|
|
pr_body=f"Closes #{ISSUE}.",
|
|
source_branch=BRANCH,
|
|
remote="prgs",
|
|
host="gitea.prgs.cc",
|
|
org=ORG,
|
|
repo=REPO,
|
|
worktree_path=self.worktree,
|
|
)
|
|
self.assertTrue(ownership["proven"], ownership["reasons"])
|
|
self.assertTrue(ownership["has_author_lock"])
|
|
self.assertEqual(ownership["matched_issue"], ISSUE)
|
|
|
|
|
|
class TestRecoveryRejectionsEndToEnd(LockIssueEndToEndBase):
|
|
def assert_lock_refused(self, **kwargs):
|
|
with self.assertRaises((ValueError, RuntimeError)) as ctx:
|
|
self.run_lock(**kwargs)
|
|
return str(ctx.exception)
|
|
|
|
def test_competing_pr_still_blocked(self):
|
|
self.write_durable_lock(pid=dead_pid())
|
|
message = self.assert_lock_refused(
|
|
open_prs=[owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
|
|
branch_names=[BRANCH],
|
|
)
|
|
self.assertIn("already covers issue", message)
|
|
|
|
def test_multiple_linked_open_prs_blocked(self):
|
|
self.write_durable_lock(pid=dead_pid())
|
|
message = self.assert_lock_refused(
|
|
open_prs=[owning_pr(), owning_pr(number=OTHER_PR, ref=OTHER_BRANCH)],
|
|
)
|
|
self.assertIn("already covers issue", message)
|
|
|
|
def test_owning_pr_on_a_different_head_blocked(self):
|
|
self.write_durable_lock(pid=dead_pid())
|
|
self.assert_lock_refused(open_prs=[owning_pr(sha=OTHER_HEAD)])
|
|
|
|
def test_lock_registered_to_a_different_worktree_blocked(self):
|
|
self.write_durable_lock(
|
|
pid=dead_pid(), worktree=os.path.join(self.worktree, "elsewhere")
|
|
)
|
|
self.assert_lock_refused(open_prs=[owning_pr()])
|
|
|
|
def test_dirty_worktree_blocked(self):
|
|
self.write_durable_lock(pid=dead_pid())
|
|
self.assert_lock_refused(
|
|
open_prs=[owning_pr()], porcelain=" M gitea_mcp_server.py"
|
|
)
|
|
|
|
def test_worktree_parked_on_another_branch_blocked(self):
|
|
self.write_durable_lock(pid=dead_pid())
|
|
self.assert_lock_refused(
|
|
open_prs=[owning_pr()], current_branch="master"
|
|
)
|
|
|
|
def test_local_head_differing_from_remote_blocked(self):
|
|
self.write_durable_lock(pid=dead_pid())
|
|
self.assert_lock_refused(
|
|
open_prs=[owning_pr()], head_sha=OTHER_HEAD
|
|
)
|
|
|
|
def test_live_prior_pid_blocked(self):
|
|
self.write_durable_lock(pid=os.getpid())
|
|
self.assert_lock_refused(open_prs=[owning_pr()])
|
|
|
|
def test_new_claim_without_prior_lock_still_requires_base_equivalence(self):
|
|
"""AC10: no durable lock → no recovery → base-equivalence still rules."""
|
|
self.assert_lock_refused(open_prs=[], branch_names=[])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|