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()