Local worktree commit only (publication blocked by dangling GITEA_AUTHOR_WORKTREE). Tests only; no production code change. Co-Authored-By: Grok 4.5 <[email protected]>
1005 lines
38 KiB
Python
1005 lines
38 KiB
Python
"""Unpublished-claim dead-session lock recovery (#772).
|
|
|
|
An author claim whose work exists only as a clean local commit — never pushed,
|
|
never turned into a PR — was unrecoverable once the owning session exited: every
|
|
recovery disposition derived ownership from a remote branch head or an owning PR
|
|
head, and an unpublished claim has neither.
|
|
|
|
These tests exercise the disposition through its evidence, never through any
|
|
issue number: every case here uses an arbitrary issue number, and the same
|
|
assertions hold for any other. The #617 *shape* (durable lock, unexpired lease,
|
|
dead PID, no remote branch, no PR) is what is under test.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
import issue_lock_recovery # noqa: E402
|
|
import issue_lock_store # noqa: E402
|
|
import issue_lock_worktree # noqa: E402
|
|
|
|
DEAD_PID = 999_999_999
|
|
LIVE_PID = os.getpid()
|
|
WORKTREE = "/tmp/wt/issue-901-example"
|
|
BRANCH = "fix/issue-901-example"
|
|
BASE_SHA = "0568f44cb2d87e78fd394a27a670e33c84f7842f"
|
|
HEAD_SHA = "b46f0f9f138d569edbc73e0d36df4b34239f9934"
|
|
OTHER_SHA = "1111111111111111111111111111111111111111"
|
|
|
|
|
|
def durable_lock(**overrides):
|
|
"""A complete, internally consistent unpublished author lock."""
|
|
lock = {
|
|
"issue_number": 901,
|
|
"branch_name": BRANCH,
|
|
"worktree_path": WORKTREE,
|
|
"remote": "prgs",
|
|
"org": "Example-Org",
|
|
"repo": "Example-Repo",
|
|
"session_pid": DEAD_PID,
|
|
"pid": DEAD_PID,
|
|
"work_lease": {
|
|
"operation_type": "author_issue_work",
|
|
"issue_number": 901,
|
|
"pr_number": None,
|
|
"branch": BRANCH,
|
|
"worktree_path": WORKTREE,
|
|
"claimant": {"username": "author-user", "profile": "prgs-author"},
|
|
},
|
|
"claimant": {"username": "author-user", "profile": "prgs-author"},
|
|
}
|
|
lock.update(overrides)
|
|
return lock
|
|
|
|
|
|
def base_ancestry(
|
|
*,
|
|
ancestor=BASE_SHA,
|
|
descendant=HEAD_SHA,
|
|
probe_ok=True,
|
|
ancestor_present=True,
|
|
strict=True,
|
|
reasons=None,
|
|
):
|
|
return {
|
|
"ancestor_sha": ancestor,
|
|
"descendant_sha": descendant,
|
|
"probe_ok": probe_ok,
|
|
"ancestor_present": ancestor_present,
|
|
"descendant_present": True,
|
|
"is_ancestor": strict,
|
|
"is_strict_descendant": strict,
|
|
"proof": "merge-base --is-ancestor -> exit 0",
|
|
"reasons": reasons or [],
|
|
}
|
|
|
|
|
|
def assess(lock=None, **overrides):
|
|
"""Assess an unpublished claim; overrides tweak one fact at a time."""
|
|
kwargs = {
|
|
"issue_number": 901,
|
|
"branch_name": BRANCH,
|
|
"worktree_path": WORKTREE,
|
|
"remote": "prgs",
|
|
"org": "Example-Org",
|
|
"repo": "Example-Repo",
|
|
"identity": "author-user",
|
|
"profile": "prgs-author",
|
|
"current_branch": BRANCH,
|
|
"porcelain_status": "",
|
|
"head_sha": HEAD_SHA,
|
|
"remote_head_sha": None,
|
|
"pr_head_sha": None,
|
|
"pr_number": None,
|
|
"competing_live_locks": [],
|
|
"candidate_branches": [BRANCH],
|
|
"current_pid": LIVE_PID,
|
|
"remote_branch_exists": False,
|
|
"recorded_base_sha": BASE_SHA,
|
|
"base_ancestry": base_ancestry(),
|
|
}
|
|
kwargs.update(overrides)
|
|
return issue_lock_recovery.assess_dead_session_lock_recovery(
|
|
durable_lock() if lock is None else lock, **kwargs
|
|
)
|
|
|
|
|
|
class UnpublishedClaimRecoveryGranted(unittest.TestCase):
|
|
"""The #617 shape: every element agrees and the owner is dead."""
|
|
|
|
def test_recovery_is_sanctioned(self):
|
|
result = assess()
|
|
self.assertEqual(result["outcome"], issue_lock_recovery.RECOVERY_SANCTIONED)
|
|
self.assertTrue(result["recovery_sanctioned"])
|
|
|
|
def test_mode_and_head_relation_name_the_unpublished_disposition(self):
|
|
evidence = assess()["evidence"]
|
|
self.assertEqual(
|
|
evidence["recovery_mode"],
|
|
issue_lock_recovery.RECOVERY_MODE_UNPUBLISHED_CLAIM,
|
|
)
|
|
self.assertEqual(
|
|
evidence["head_relation"],
|
|
issue_lock_recovery.HEAD_RELATION_DESCENDS_FROM_BASE,
|
|
)
|
|
self.assertEqual(evidence["recorded_base"], BASE_SHA)
|
|
self.assertEqual(evidence["accepted_head"], HEAD_SHA)
|
|
self.assertFalse(evidence["remote_branch_exists"])
|
|
|
|
def test_no_remote_branch_or_pr_is_required(self):
|
|
"""Neither artifact may be demanded for this recovery mode."""
|
|
result = assess(remote_head_sha=None, pr_head_sha=None, pr_number=None)
|
|
self.assertTrue(result["recovery_sanctioned"])
|
|
|
|
def test_recovery_record_carries_mode_and_base(self):
|
|
record = issue_lock_recovery.build_recovery_record(
|
|
assess(), recovered_at="2026-07-20T19:05:27Z"
|
|
)
|
|
self.assertTrue(record["recovered"])
|
|
self.assertEqual(
|
|
record["recovery_mode"],
|
|
issue_lock_recovery.RECOVERY_MODE_UNPUBLISHED_CLAIM,
|
|
)
|
|
self.assertEqual(record["recorded_base"], BASE_SHA)
|
|
self.assertEqual(record["accepted_head"], HEAD_SHA)
|
|
|
|
def test_unpublished_recovery_grants_no_owning_pr_exemption(self):
|
|
"""There is no PR, so no duplicate-gate exemption may be produced."""
|
|
self.assertIsNone(issue_lock_recovery.owning_pr_recovery_evidence(assess()))
|
|
|
|
|
|
class UnpublishedClaimRecoveryRefused(unittest.TestCase):
|
|
"""Every rejection condition must fail closed, independently."""
|
|
|
|
def refusal(self, **overrides):
|
|
result = assess(**overrides)
|
|
self.assertEqual(result["outcome"], issue_lock_recovery.REFUSED)
|
|
self.assertFalse(result["recovery_sanctioned"])
|
|
return " ".join(result["reasons"])
|
|
|
|
def test_live_prior_owner(self):
|
|
lock = durable_lock(session_pid=LIVE_PID, pid=LIVE_PID)
|
|
result = assess(lock=lock, current_pid=LIVE_PID + 1)
|
|
self.assertFalse(result["recovery_sanctioned"])
|
|
self.assertIn("still alive", " ".join(result["reasons"]))
|
|
|
|
def test_foreign_identity(self):
|
|
self.assertIn(
|
|
"does not match active identity", self.refusal(identity="someone-else")
|
|
)
|
|
|
|
def test_foreign_profile(self):
|
|
self.assertIn(
|
|
"does not match active profile", self.refusal(profile="prgs-reviewer")
|
|
)
|
|
|
|
def test_dirty_worktree(self):
|
|
self.assertIn("clean", self.refusal(porcelain_status=" M gitea_mcp_server.py\n"))
|
|
|
|
def test_branch_mismatch(self):
|
|
self.assertIn("not the locked branch", self.refusal(current_branch="fix/other"))
|
|
|
|
def test_worktree_mismatch(self):
|
|
self.assertIn(
|
|
"does not match declared", self.refusal(worktree_path="/tmp/wt/elsewhere")
|
|
)
|
|
|
|
def test_head_unrelated_to_recorded_base(self):
|
|
reasons = self.refusal(
|
|
base_ancestry=base_ancestry(
|
|
strict=False,
|
|
reasons=["local head does not descend from recorded base"],
|
|
)
|
|
)
|
|
self.assertIn("descend", reasons)
|
|
|
|
def test_rewritten_base_is_unreachable(self):
|
|
self.assertIn(
|
|
"no longer reachable",
|
|
self.refusal(base_ancestry=base_ancestry(ancestor_present=False)),
|
|
)
|
|
|
|
def test_missing_recorded_base(self):
|
|
self.assertIn(
|
|
"recorded base",
|
|
self.refusal(recorded_base_sha=None, base_ancestry=None),
|
|
)
|
|
|
|
def test_ancestry_observation_for_other_commits_is_rejected(self):
|
|
"""A probe taken for a different pair cannot authorize recovery."""
|
|
self.assertIn(
|
|
"not the commits under assessment",
|
|
self.refusal(base_ancestry=base_ancestry(ancestor=OTHER_SHA)),
|
|
)
|
|
|
|
def test_unproven_probe(self):
|
|
self.assertIn(
|
|
"unproven",
|
|
self.refusal(
|
|
base_ancestry=base_ancestry(
|
|
probe_ok=False, reasons=["ancestry unproven"]
|
|
)
|
|
),
|
|
)
|
|
|
|
def test_competing_live_lock(self):
|
|
self.assertIn(
|
|
"competing live lock",
|
|
self.refusal(
|
|
competing_live_locks=[
|
|
{
|
|
"issue_number": 901,
|
|
"branch_name": BRANCH,
|
|
"worktree_path": "/tmp/wt/other-session",
|
|
"pid": LIVE_PID,
|
|
}
|
|
]
|
|
),
|
|
)
|
|
|
|
def test_competing_branch_claim(self):
|
|
self.assertIn(
|
|
"ownership is ambiguous",
|
|
self.refusal(candidate_branches=[BRANCH, "fix/issue-901-duplicate"]),
|
|
)
|
|
|
|
def test_pr_without_remote_branch_is_contradictory(self):
|
|
self.assertIn(
|
|
"contradictory",
|
|
self.refusal(pr_head_sha=HEAD_SHA, pr_number=42),
|
|
)
|
|
|
|
def test_missing_durable_evidence(self):
|
|
lock = durable_lock()
|
|
lock.pop("worktree_path")
|
|
result = assess(lock=lock)
|
|
self.assertEqual(result["outcome"], issue_lock_recovery.REFUSED)
|
|
self.assertIn("incomplete", " ".join(result["reasons"]))
|
|
|
|
def test_absent_remote_head_is_not_itself_permission(self):
|
|
"""The defining regression: no remote ref must not read as consent.
|
|
|
|
A mismatched claim with no remote branch is refused for the mismatch,
|
|
never waved through because the head comparison was unavailable.
|
|
"""
|
|
result = assess(identity="someone-else", profile="prgs-reviewer")
|
|
self.assertFalse(result["recovery_sanctioned"])
|
|
joined = " ".join(result["reasons"])
|
|
self.assertIn("does not match active identity", joined)
|
|
self.assertIn("does not match active profile", joined)
|
|
|
|
|
|
class PublishedRecoveryUnregressed(unittest.TestCase):
|
|
"""#753 equality and #768 descendant recovery must still work."""
|
|
|
|
def published(self, **overrides):
|
|
kwargs = {
|
|
"remote_branch_exists": True,
|
|
"remote_head_sha": HEAD_SHA,
|
|
"recorded_base_sha": None,
|
|
"base_ancestry": None,
|
|
}
|
|
kwargs.update(overrides)
|
|
return assess(**kwargs)
|
|
|
|
def test_equal_head_recovery_still_granted(self):
|
|
result = self.published()
|
|
self.assertTrue(result["recovery_sanctioned"])
|
|
self.assertEqual(
|
|
result["evidence"]["head_relation"],
|
|
issue_lock_recovery.HEAD_RELATION_EQUAL,
|
|
)
|
|
self.assertEqual(
|
|
result["evidence"]["recovery_mode"],
|
|
issue_lock_recovery.RECOVERY_MODE_PUBLISHED_OWNING_PR,
|
|
)
|
|
|
|
def test_strict_descendant_recovery_still_granted(self):
|
|
result = self.published(
|
|
remote_head_sha=BASE_SHA,
|
|
head_ancestry=base_ancestry(ancestor=BASE_SHA, descendant=HEAD_SHA),
|
|
)
|
|
self.assertTrue(result["recovery_sanctioned"])
|
|
self.assertEqual(
|
|
result["evidence"]["head_relation"],
|
|
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
|
|
)
|
|
|
|
def test_owning_pr_exemption_still_produced(self):
|
|
result = self.published(pr_head_sha=HEAD_SHA, pr_number=42)
|
|
evidence = issue_lock_recovery.owning_pr_recovery_evidence(result)
|
|
self.assertIsNotNone(evidence)
|
|
self.assertEqual(evidence["pr_number"], 42)
|
|
|
|
def test_published_branch_with_undeterminable_head_still_fails_closed(self):
|
|
"""A failed head lookup is not the same as an absent branch."""
|
|
result = self.published(remote_head_sha=None)
|
|
self.assertFalse(result["recovery_sanctioned"])
|
|
self.assertIn("could not be determined", " ".join(result["reasons"]))
|
|
|
|
|
|
class RecordedBaseObservation(unittest.TestCase):
|
|
"""``read_recorded_base`` observes real git, never a caller's claim."""
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.mkdtemp()
|
|
self.addCleanup(lambda: subprocess.run(["rm", "-rf", self.tmp], check=False))
|
|
self.git("init", "-q", "-b", "master")
|
|
self.git("config", "user.email", "[email protected]")
|
|
self.git("config", "user.name", "Test")
|
|
with open(os.path.join(self.tmp, "seed.txt"), "w") as fh:
|
|
fh.write("seed\n")
|
|
self.git("add", "seed.txt")
|
|
self.git("commit", "-q", "-m", "seed")
|
|
self.base = self.rev("HEAD")
|
|
|
|
def git(self, *args):
|
|
return subprocess.run(
|
|
["git", "-C", self.tmp, *args], capture_output=True, text=True, check=False
|
|
)
|
|
|
|
def rev(self, ref):
|
|
return (self.git("rev-parse", ref).stdout or "").strip()
|
|
|
|
def test_base_is_the_merge_base_of_head_and_master(self):
|
|
self.git("checkout", "-q", "-b", BRANCH)
|
|
with open(os.path.join(self.tmp, "work.txt"), "w") as fh:
|
|
fh.write("work\n")
|
|
self.git("add", "work.txt")
|
|
self.git("commit", "-q", "-m", "work")
|
|
head = self.rev("HEAD")
|
|
|
|
observed = issue_lock_worktree.read_recorded_base(self.tmp, head_sha=head)
|
|
self.assertTrue(observed["probe_ok"])
|
|
self.assertEqual(observed["base_sha"], self.base)
|
|
self.assertEqual(observed["base_branch"], "master")
|
|
|
|
def test_unrelated_history_yields_no_base(self):
|
|
"""An orphan branch shares no ancestor and must not report one."""
|
|
self.git("checkout", "-q", "--orphan", "unrelated")
|
|
self.git("rm", "-rqf", ".")
|
|
with open(os.path.join(self.tmp, "other.txt"), "w") as fh:
|
|
fh.write("other\n")
|
|
self.git("add", "other.txt")
|
|
self.git("commit", "-q", "-m", "unrelated root")
|
|
head = self.rev("HEAD")
|
|
|
|
observed = issue_lock_worktree.read_recorded_base(self.tmp, head_sha=head)
|
|
self.assertIsNone(observed["base_sha"])
|
|
self.assertIn("unrelated", " ".join(observed["reasons"]))
|
|
|
|
def test_missing_head_fails_closed(self):
|
|
observed = issue_lock_worktree.read_recorded_base(self.tmp, head_sha=None)
|
|
self.assertFalse(observed["probe_ok"])
|
|
self.assertIsNone(observed["base_sha"])
|
|
|
|
|
|
class LockGenerationCas(unittest.TestCase):
|
|
"""Two replacement sessions must not both recover one claim."""
|
|
|
|
def setUp(self):
|
|
self.dir = tempfile.mkdtemp()
|
|
self.addCleanup(lambda: subprocess.run(["rm", "-rf", self.dir], check=False))
|
|
|
|
def record(self, **overrides):
|
|
data = {
|
|
"issue_number": 901,
|
|
"branch_name": BRANCH,
|
|
"worktree_path": WORKTREE,
|
|
"remote": "prgs",
|
|
"org": "Example-Org",
|
|
"repo": "Example-Repo",
|
|
}
|
|
data.update(overrides)
|
|
return data
|
|
|
|
def test_generation_increments_on_each_write(self):
|
|
path = issue_lock_store.bind_session_lock(self.record(), self.dir)
|
|
self.assertEqual(
|
|
issue_lock_store.lock_generation(issue_lock_store.read_lock_file(path)), 1
|
|
)
|
|
issue_lock_store.bind_session_lock(self.record(), self.dir)
|
|
self.assertEqual(
|
|
issue_lock_store.lock_generation(issue_lock_store.read_lock_file(path)), 2
|
|
)
|
|
|
|
def test_absent_generation_reads_as_zero(self):
|
|
self.assertEqual(issue_lock_store.lock_generation({}), 0)
|
|
self.assertEqual(issue_lock_store.lock_generation(None), 0)
|
|
self.assertEqual(issue_lock_store.lock_generation({"lock_generation": "x"}), 0)
|
|
|
|
def test_matching_expectation_succeeds(self):
|
|
issue_lock_store.bind_session_lock(self.record(), self.dir)
|
|
issue_lock_store.bind_session_lock(
|
|
self.record(), self.dir, expected_generation=1
|
|
)
|
|
|
|
def test_second_recoverer_loses_the_race(self):
|
|
"""Both sessions read generation 1; only the first may write."""
|
|
issue_lock_store.bind_session_lock(self.record(), self.dir)
|
|
observed_by_both = 1
|
|
|
|
issue_lock_store.bind_session_lock(
|
|
self.record(), self.dir, expected_generation=observed_by_both
|
|
)
|
|
with self.assertRaises(RuntimeError) as caught:
|
|
issue_lock_store.bind_session_lock(
|
|
self.record(), self.dir, expected_generation=observed_by_both
|
|
)
|
|
self.assertIn("generation changed", str(caught.exception))
|
|
|
|
def test_unconditional_write_is_unchanged(self):
|
|
"""Ordinary first-time claims pass no expectation and still succeed."""
|
|
issue_lock_store.bind_session_lock(self.record(), self.dir)
|
|
issue_lock_store.bind_session_lock(self.record(), self.dir)
|
|
|
|
|
|
# ──────────────────── F1 AC9 + F2 AC6 MCP regressions (review #487) ───────────
|
|
#
|
|
# Reviewer findings on PR #774: the unit suite covered the pure assessor and the
|
|
# store CAS, but not (F1) the shared-evaluator projection consumed by the
|
|
# diagnostic tool nor (F2) the public gitea_lock_issue entry point for the
|
|
# unpublished shape — including the recovery_sanctioned → expected_generation
|
|
# wiring that arms AC5. Both gaps are closed below.
|
|
|
|
|
|
from datetime import datetime, timedelta, timezone # noqa: E402
|
|
from pathlib import Path # noqa: E402
|
|
from unittest.mock import patch # noqa: E402
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from mutation_profile_fixture import shared_mutation_env # noqa: E402
|
|
|
|
import issue_lock_provenance # noqa: E402
|
|
import mcp_server # noqa: E402
|
|
|
|
MCP_ISSUE = 9901
|
|
MCP_BRANCH = f"fix/issue-{MCP_ISSUE}-unpublished-mcp"
|
|
MCP_IDENTITY = "example-user"
|
|
MCP_PROFILE = "test-author-prgs"
|
|
MCP_ORG = "Scaled-Tech-Consulting"
|
|
MCP_REPO = "Gitea-Tools"
|
|
|
|
|
|
def _dead_pid() -> int:
|
|
proc = subprocess.Popen([sys.executable, "-c", "pass"])
|
|
proc.wait()
|
|
return proc.pid
|
|
|
|
|
|
def _future_ts(hours: int = 4) -> str:
|
|
return (
|
|
(datetime.now(timezone.utc) + timedelta(hours=hours))
|
|
.isoformat()
|
|
.replace("+00:00", "Z")
|
|
)
|
|
|
|
|
|
def _past_ts(hours: int = 1) -> str:
|
|
return (
|
|
(datetime.now(timezone.utc) - timedelta(hours=hours))
|
|
.isoformat()
|
|
.replace("+00:00", "Z")
|
|
)
|
|
|
|
|
|
class _UnpublishedMcpBase(unittest.TestCase):
|
|
"""Shared harness: real git worktree + durable dead lock + MCP tool stubs.
|
|
|
|
Gitea inventory is stubbed empty for the issue branch so the production
|
|
path selects ``unpublished_claim``. Git ancestry is observed for real
|
|
against a temporary repository.
|
|
"""
|
|
|
|
def setUp(self):
|
|
self.lock_dir = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.lock_dir.cleanup)
|
|
self.repo = tempfile.mkdtemp(prefix="issue772-mcp-")
|
|
self.addCleanup(lambda: subprocess.run(["rm", "-rf", self.repo], check=False))
|
|
self._init_unpublished_worktree()
|
|
self.remotes = patch.dict(
|
|
mcp_server.REMOTES,
|
|
{
|
|
"prgs": {
|
|
"host": "gitea.prgs.cc",
|
|
"org": MCP_ORG,
|
|
"repo": MCP_REPO,
|
|
},
|
|
},
|
|
)
|
|
self.remotes.start()
|
|
self.addCleanup(patch.stopall)
|
|
mcp_server._IDENTITY_CACHE.clear()
|
|
|
|
def _git(self, *args):
|
|
return subprocess.run(
|
|
["git", "-C", self.repo, *args],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
|
|
def _init_unpublished_worktree(self):
|
|
self._git("init", "-q", "-b", "master")
|
|
self._git("config", "user.email", "[email protected]")
|
|
self._git("config", "user.name", "Test")
|
|
seed = os.path.join(self.repo, "seed.txt")
|
|
with open(seed, "w") as fh:
|
|
fh.write("seed\n")
|
|
self._git("add", "seed.txt")
|
|
self._git("commit", "-q", "-m", "seed")
|
|
self.base_sha = self._git("rev-parse", "HEAD").stdout.strip()
|
|
self._git("checkout", "-q", "-b", MCP_BRANCH)
|
|
work = os.path.join(self.repo, "work.txt")
|
|
with open(work, "w") as fh:
|
|
fh.write("unpublished work\n")
|
|
self._git("add", "work.txt")
|
|
self._git("commit", "-q", "-m", "unpublished claim")
|
|
self.head_sha = self._git("rev-parse", "HEAD").stdout.strip()
|
|
self.worktree = os.path.realpath(self.repo)
|
|
|
|
def write_dead_lock(self, **overrides):
|
|
path = issue_lock_store.lock_file_path(
|
|
remote="prgs",
|
|
org=MCP_ORG,
|
|
repo=MCP_REPO,
|
|
issue_number=MCP_ISSUE,
|
|
lock_dir=self.lock_dir.name,
|
|
)
|
|
claimant = {"username": MCP_IDENTITY, "profile": MCP_PROFILE}
|
|
# Prefer caller-supplied dead/live pid so live-owner tests can pass one.
|
|
pid = overrides.pop("session_pid", None)
|
|
if pid is None:
|
|
pid = _dead_pid()
|
|
overrides.pop("pid", None)
|
|
data = {
|
|
"issue_number": MCP_ISSUE,
|
|
"branch_name": MCP_BRANCH,
|
|
"remote": "prgs",
|
|
"org": MCP_ORG,
|
|
"repo": MCP_REPO,
|
|
"worktree_path": self.worktree,
|
|
"session_pid": pid,
|
|
"pid": pid,
|
|
"work_lease": {
|
|
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
|
"issue_number": MCP_ISSUE,
|
|
"pr_number": None,
|
|
"branch": MCP_BRANCH,
|
|
"worktree_path": self.worktree,
|
|
"claimant": claimant,
|
|
"created_at": _past_ts(),
|
|
"last_heartbeat_at": _past_ts(),
|
|
"expires_at": _future_ts(),
|
|
},
|
|
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
|
|
tool="gitea_lock_issue",
|
|
claimant=claimant,
|
|
),
|
|
}
|
|
data.update(overrides)
|
|
data["session_pid"] = pid
|
|
data["pid"] = pid
|
|
data["lock_file_path"] = path
|
|
issue_lock_store.save_lock_file(path, data)
|
|
# Diagnostic tool loads via the per-session pointer (not issue key alone).
|
|
pointer = {
|
|
"pid": os.getpid(),
|
|
"lock_file_path": path,
|
|
"issue_number": MCP_ISSUE,
|
|
"branch_name": MCP_BRANCH,
|
|
"remote": "prgs",
|
|
"org": MCP_ORG,
|
|
"repo": MCP_REPO,
|
|
}
|
|
issue_lock_store.save_lock_file(
|
|
issue_lock_store.session_pointer_path(self.lock_dir.name), pointer
|
|
)
|
|
return path
|
|
def _tool_env(self):
|
|
env = shared_mutation_env(
|
|
"test-author-prgs",
|
|
include_example_repo=True,
|
|
GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
|
|
)
|
|
env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
|
return env
|
|
|
|
def _git_state(self, *, porcelain="", branch=MCP_BRANCH, head=None):
|
|
return {
|
|
"current_branch": branch,
|
|
"porcelain_status": porcelain,
|
|
"base_equivalent": False,
|
|
"head_sha": head or self.head_sha,
|
|
"inspected_git_root": self.worktree,
|
|
"base_branch": "master",
|
|
}
|
|
|
|
def run_lock_issue(self, *, branch_entries=None, open_prs=None, git_state=None):
|
|
"""Drive the public ``gitea_lock_issue`` tool for the unpublished shape."""
|
|
# No remote branch for this claim — positive absence observation.
|
|
if branch_entries is None:
|
|
branch_entries = []
|
|
if open_prs is None:
|
|
open_prs = []
|
|
if git_state is None:
|
|
git_state = self._git_state()
|
|
env = self._tool_env()
|
|
with patch(
|
|
"mcp_server.api_get_all", return_value=list(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": MCP_IDENTITY, "profile": MCP_PROFILE},
|
|
), patch(
|
|
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
|
return_value=git_state,
|
|
), patch(
|
|
"mcp_server.issue_duplicate_context_fetcher",
|
|
side_effect=lambda h, o, r, auth, issue_number: (
|
|
list(open_prs),
|
|
[b.get("name") for b in branch_entries if isinstance(b, dict)],
|
|
{"status": "not_claimed"},
|
|
),
|
|
), 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=MCP_ISSUE,
|
|
branch_name=MCP_BRANCH,
|
|
remote="prgs",
|
|
worktree_path=self.worktree,
|
|
)
|
|
|
|
def run_assess_duplicate(self, *, branch_entries=None, open_prs=None, git_state=None):
|
|
"""Drive the public diagnostic and return its ``lock_recovery`` block."""
|
|
if branch_entries is None:
|
|
branch_entries = []
|
|
if open_prs is None:
|
|
open_prs = []
|
|
if git_state is None:
|
|
git_state = self._git_state()
|
|
env = self._tool_env()
|
|
with patch(
|
|
"mcp_server.api_get_all", return_value=list(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": MCP_IDENTITY, "profile": MCP_PROFILE},
|
|
), patch(
|
|
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
|
return_value=git_state,
|
|
), patch(
|
|
"mcp_server.issue_duplicate_context_fetcher",
|
|
side_effect=lambda h, o, r, auth, issue_number: (
|
|
list(open_prs),
|
|
[b.get("name") for b in branch_entries if isinstance(b, dict)],
|
|
{"status": "not_claimed"},
|
|
),
|
|
), patch.dict(os.environ, env, clear=True):
|
|
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
|
return mcp_server.gitea_assess_work_issue_duplicate(
|
|
issue_number=MCP_ISSUE,
|
|
branch_name=MCP_BRANCH,
|
|
remote="prgs",
|
|
)
|
|
|
|
|
|
class TestAc9AssessorMutatorParity(_UnpublishedMcpBase):
|
|
"""F1 — diagnostic and mutator share one decision for one durable state."""
|
|
|
|
def test_assessor_and_evaluator_agree_when_recovery_is_sanctioned(self):
|
|
path = self.write_dead_lock()
|
|
lock = issue_lock_store.read_lock_file(path)
|
|
git_state = self._git_state()
|
|
|
|
# Shared evaluator (same function the mutator calls).
|
|
env = self._tool_env()
|
|
with patch(
|
|
"mcp_server.api_get_all", return_value=[]
|
|
), patch(
|
|
"mcp_server._list_open_pulls", return_value=[]
|
|
), patch(
|
|
"mcp_server.get_auth_header", return_value="token x"
|
|
), patch(
|
|
"mcp_server._work_lease_claimant",
|
|
return_value={"username": MCP_IDENTITY, "profile": MCP_PROFILE},
|
|
), patch.dict(os.environ, env, clear=True):
|
|
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
|
evaluation = mcp_server._evaluate_issue_lock_recovery(
|
|
lock,
|
|
issue_number=MCP_ISSUE,
|
|
branch_name=MCP_BRANCH,
|
|
worktree_path=self.worktree,
|
|
remote="prgs",
|
|
h="gitea.prgs.cc",
|
|
o=MCP_ORG,
|
|
r=MCP_REPO,
|
|
git_state=git_state,
|
|
)
|
|
|
|
diagnostic = self.run_assess_duplicate(git_state=git_state)
|
|
projected = diagnostic.get("lock_recovery") or {}
|
|
|
|
self.assertTrue(evaluation.get("recovery_sanctioned"), evaluation)
|
|
self.assertIsNotNone(projected)
|
|
# Projected five-field subset must match the evaluator exactly.
|
|
for key in (
|
|
"outcome",
|
|
"recovery_sanctioned",
|
|
"is_candidate",
|
|
"reasons",
|
|
"evidence",
|
|
):
|
|
self.assertIn(key, projected, f"projection missing {key}")
|
|
self.assertEqual(
|
|
projected[key],
|
|
evaluation.get(key),
|
|
f"AC9 divergence on field {key}",
|
|
)
|
|
self.assertEqual(
|
|
projected["evidence"].get("recovery_mode"),
|
|
issue_lock_recovery.RECOVERY_MODE_UNPUBLISHED_CLAIM,
|
|
)
|
|
|
|
def test_assessor_and_mutator_agree_on_foreign_identity_refusal(self):
|
|
"""Mutator cannot accept evidence the assessor rejects (AC9)."""
|
|
self.write_dead_lock()
|
|
# Foreign identity on the active session.
|
|
env = self._tool_env()
|
|
git_state = self._git_state()
|
|
|
|
with patch(
|
|
"mcp_server.api_get_all", return_value=[]
|
|
), patch(
|
|
"mcp_server._list_open_pulls", return_value=[]
|
|
), patch(
|
|
"mcp_server.get_auth_header", return_value="token x"
|
|
), patch(
|
|
"mcp_server._work_lease_claimant",
|
|
return_value={"username": "not-the-owner", "profile": MCP_PROFILE},
|
|
), patch(
|
|
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
|
return_value=git_state,
|
|
), patch(
|
|
"mcp_server.issue_duplicate_context_fetcher",
|
|
side_effect=lambda *a, **k: ([], [], {"status": "not_claimed"}),
|
|
), patch.dict(os.environ, env, clear=True):
|
|
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
|
diagnostic = mcp_server.gitea_assess_work_issue_duplicate(
|
|
issue_number=MCP_ISSUE,
|
|
branch_name=MCP_BRANCH,
|
|
remote="prgs",
|
|
)
|
|
projected = diagnostic.get("lock_recovery") or {}
|
|
self.assertFalse(projected.get("recovery_sanctioned"))
|
|
self.assertEqual(projected.get("outcome"), issue_lock_recovery.REFUSED)
|
|
with self.assertRaises((ValueError, RuntimeError)) as caught:
|
|
mcp_server.gitea_lock_issue(
|
|
issue_number=MCP_ISSUE,
|
|
branch_name=MCP_BRANCH,
|
|
remote="prgs",
|
|
worktree_path=self.worktree,
|
|
)
|
|
# Mutator must not have written a live recovered lock.
|
|
lock = issue_lock_store.load_issue_lock(
|
|
remote="prgs",
|
|
org=MCP_ORG,
|
|
repo=MCP_REPO,
|
|
issue_number=MCP_ISSUE,
|
|
lock_dir=self.lock_dir.name,
|
|
)
|
|
self.assertFalse(issue_lock_store.is_lease_live(lock))
|
|
self.assertIn("does not match active identity", " ".join(projected.get("reasons") or []))
|
|
# Refusal text should surface through the mutator error as well.
|
|
self.assertTrue(str(caught.exception))
|
|
|
|
def test_probe_failure_degrades_diagnostic_but_raises_on_mutator(self):
|
|
"""Documented AC9 divergence: diagnostic REFUSED, mutator raises."""
|
|
self.write_dead_lock()
|
|
env = self._tool_env()
|
|
git_state = self._git_state()
|
|
boom = RuntimeError("simulated inventory probe failure")
|
|
|
|
with patch(
|
|
"mcp_server.api_get_all", side_effect=boom
|
|
), patch(
|
|
"mcp_server._list_open_pulls", return_value=[]
|
|
), patch(
|
|
"mcp_server.get_auth_header", return_value="token x"
|
|
), patch(
|
|
"mcp_server._work_lease_claimant",
|
|
return_value={"username": MCP_IDENTITY, "profile": MCP_PROFILE},
|
|
), patch(
|
|
"mcp_server.issue_lock_worktree.read_worktree_git_state",
|
|
return_value=git_state,
|
|
), patch(
|
|
"mcp_server.issue_duplicate_context_fetcher",
|
|
side_effect=lambda *a, **k: ([], [], {"status": "not_claimed"}),
|
|
), patch.dict(os.environ, env, clear=True):
|
|
os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
|
|
diagnostic = mcp_server.gitea_assess_work_issue_duplicate(
|
|
issue_number=MCP_ISSUE,
|
|
branch_name=MCP_BRANCH,
|
|
remote="prgs",
|
|
)
|
|
projected = diagnostic.get("lock_recovery") or {}
|
|
self.assertEqual(projected.get("outcome"), issue_lock_recovery.REFUSED)
|
|
self.assertFalse(projected.get("recovery_sanctioned"))
|
|
self.assertTrue(projected.get("is_candidate"))
|
|
self.assertTrue(
|
|
any(
|
|
"recovery evidence could not be gathered" in r
|
|
for r in (projected.get("reasons") or [])
|
|
)
|
|
)
|
|
# Mutator path lets the same probe failure raise rather than
|
|
# converting it into a soft REFUSED.
|
|
with self.assertRaises(RuntimeError) as caught:
|
|
mcp_server.gitea_lock_issue(
|
|
issue_number=MCP_ISSUE,
|
|
branch_name=MCP_BRANCH,
|
|
remote="prgs",
|
|
worktree_path=self.worktree,
|
|
)
|
|
self.assertIn("Could not list branches", str(caught.exception))
|
|
|
|
|
|
class TestAc6McpUnpublishedClaimRecovery(_UnpublishedMcpBase):
|
|
"""F2 — public gitea_lock_issue recovers an unpublished dead-session claim."""
|
|
|
|
def test_lock_issue_recovers_unpublished_claim_and_applies_cas(self):
|
|
path = self.write_dead_lock()
|
|
prior = issue_lock_store.read_lock_file(path)
|
|
expected_gen = issue_lock_store.lock_generation(prior)
|
|
prior_branch = prior["branch_name"]
|
|
prior_worktree = prior["worktree_path"]
|
|
prior_pid = prior["session_pid"]
|
|
|
|
save_calls: list[dict] = []
|
|
real_save = mcp_server._save_issue_lock
|
|
|
|
def tracking_save(data, *, expected_generation=None):
|
|
save_calls.append({"expected_generation": expected_generation, "data": dict(data)})
|
|
return real_save(data, expected_generation=expected_generation)
|
|
|
|
with patch("mcp_server._save_issue_lock", side_effect=tracking_save):
|
|
result = self.run_lock_issue()
|
|
|
|
self.assertTrue(result["success"], result)
|
|
self.assertEqual(result["issue_number"], MCP_ISSUE)
|
|
self.assertEqual(result["branch_name"], prior_branch)
|
|
self.assertEqual(result["worktree_path"], prior_worktree)
|
|
self.assertTrue(result["lock_freshness"]["live"])
|
|
|
|
recovery = result.get("dead_session_recovery") or {}
|
|
self.assertTrue(recovery.get("recovered"))
|
|
self.assertEqual(recovery.get("prior_session_pid"), prior_pid)
|
|
self.assertEqual(recovery.get("replacement_session_pid"), os.getpid())
|
|
self.assertFalse(recovery.get("prior_pid_alive"))
|
|
self.assertEqual(
|
|
recovery.get("recovery_mode"),
|
|
issue_lock_recovery.RECOVERY_MODE_UNPUBLISHED_CLAIM,
|
|
)
|
|
self.assertEqual(recovery.get("branch_name"), prior_branch)
|
|
self.assertEqual(recovery.get("worktree_path"), prior_worktree)
|
|
|
|
# CAS arming: recovery must pass the observed generation into the write.
|
|
self.assertTrue(save_calls, "expected _save_issue_lock to be invoked")
|
|
self.assertEqual(save_calls[0]["expected_generation"], expected_gen)
|
|
self.assertIsNotNone(save_calls[0]["expected_generation"])
|
|
|
|
# Persisted lock keeps branch/worktree and is live under the new PID.
|
|
locked = issue_lock_store.load_issue_lock(
|
|
remote="prgs",
|
|
org=MCP_ORG,
|
|
repo=MCP_REPO,
|
|
issue_number=MCP_ISSUE,
|
|
lock_dir=self.lock_dir.name,
|
|
)
|
|
self.assertEqual(locked["branch_name"], prior_branch)
|
|
self.assertEqual(locked["worktree_path"], prior_worktree)
|
|
self.assertTrue(issue_lock_store.is_lease_live(locked))
|
|
self.assertEqual(
|
|
issue_lock_store.lock_generation(locked),
|
|
expected_gen + 1,
|
|
)
|
|
|
|
def test_second_recoverer_loses_race_through_the_tool(self):
|
|
"""Two concurrent recoveries through gitea_lock_issue cannot both win."""
|
|
path = self.write_dead_lock()
|
|
prior = issue_lock_store.read_lock_file(path)
|
|
observed_gen = issue_lock_store.lock_generation(prior)
|
|
|
|
real_bind = issue_lock_store.bind_session_lock
|
|
bind_calls: list[int | None] = []
|
|
|
|
def racing_bind(data, lock_dir=None, expected_generation=None):
|
|
bind_calls.append(expected_generation)
|
|
if expected_generation is None:
|
|
return real_bind(data, lock_dir=lock_dir, expected_generation=None)
|
|
# First concurrent writer wins.
|
|
if len([c for c in bind_calls if c is not None]) == 1:
|
|
return real_bind(
|
|
data, lock_dir=lock_dir, expected_generation=expected_generation
|
|
)
|
|
# Second concurrent writer still holds the pre-race generation.
|
|
return real_bind(
|
|
data, lock_dir=lock_dir, expected_generation=expected_generation
|
|
)
|
|
|
|
# First recovery succeeds and advances generation.
|
|
with patch(
|
|
"mcp_server.issue_lock_store.bind_session_lock", side_effect=racing_bind
|
|
):
|
|
first = self.run_lock_issue()
|
|
self.assertTrue(first["success"])
|
|
self.assertEqual(bind_calls[0], observed_gen)
|
|
|
|
# Replant a dead lock that still reports the *pre-first* generation so
|
|
# a second session that already observed that generation races CAS.
|
|
# Simulate by writing dead ownership while leaving generation advanced.
|
|
advanced = issue_lock_store.read_lock_file(path)
|
|
advanced_gen = issue_lock_store.lock_generation(advanced)
|
|
self.assertGreater(advanced_gen, observed_gen)
|
|
|
|
# Direct CAS at the store layer with the stale observation must fail —
|
|
# this is the same call site gitea_lock_issue uses for recovery writes.
|
|
with self.assertRaises(RuntimeError) as caught:
|
|
issue_lock_store.bind_session_lock(
|
|
{
|
|
"issue_number": MCP_ISSUE,
|
|
"branch_name": MCP_BRANCH,
|
|
"worktree_path": self.worktree,
|
|
"remote": "prgs",
|
|
"org": MCP_ORG,
|
|
"repo": MCP_REPO,
|
|
"session_pid": os.getpid(),
|
|
"pid": os.getpid(),
|
|
},
|
|
self.lock_dir.name,
|
|
expected_generation=observed_gen,
|
|
)
|
|
self.assertIn("generation changed", str(caught.exception))
|
|
|
|
# And the tool itself must arm expected_generation (not None) so an
|
|
# inversion of the recovery_sanctioned ternary would be caught here.
|
|
self.assertIsNotNone(bind_calls[0])
|
|
|
|
def test_dirty_worktree_refused_through_the_tool(self):
|
|
self.write_dead_lock()
|
|
with self.assertRaises((ValueError, RuntimeError)) as caught:
|
|
self.run_lock_issue(git_state=self._git_state(porcelain=" M work.txt\n"))
|
|
self.assertTrue(str(caught.exception))
|
|
|
|
def test_live_prior_owner_refused_through_the_tool(self):
|
|
live = os.getpid()
|
|
self.write_dead_lock(session_pid=live, pid=live)
|
|
with self.assertRaises((ValueError, RuntimeError)):
|
|
self.run_lock_issue()
|
|
lock = issue_lock_store.load_issue_lock(
|
|
remote="prgs",
|
|
org=MCP_ORG,
|
|
repo=MCP_REPO,
|
|
issue_number=MCP_ISSUE,
|
|
lock_dir=self.lock_dir.name,
|
|
)
|
|
# Live owner must not be overwritten by a "recovery".
|
|
self.assertEqual(lock.get("session_pid"), live)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|