"""Tests for issue #970: safely resolve worktree bindings whose paths are missing. Covers all acceptance criteria for #970: - missing path reporting with repo/branch/issue/session/lease/host correlation - fail closed on unavailable host or mount failure - fail closed on moved worktree paths - fail closed on live lease or active session/process - atomic pre-mutation re-validation preventing recreation races - targeted retirement preserving unrelated worktrees & metadata - safe and idempotent repeated execution - worktrees dimension resolution post-cleanup - resolution of the two historical post-PR #968 records (#795 / #635) Plus the adversarial coverage required by review 644 (B5). Every test below drives the *production* paths — the registered MCP tools loaded through ``mcp_server``, the real ``ControlPlaneDB`` compare-and-swap, and the real ``audit_reconciliation_mode`` cleanup gate — so each one fails if the corresponding protection is removed: - B1: lease status / ownership / session / path drift between audit and apply - B2: server-enforced cleanup authorization at both the task-capability and the production mutation boundary, including a rejected ``operator_authorized`` - B3: expected-path compare-and-swap on lease *and* checkpoint retirement - B4: live-session, trustworthy-owner, and conflicting-issue-lock evidence """ from __future__ import annotations import json import os import shutil import subprocess import sys import tempfile import unittest from datetime import datetime, timedelta, timezone from unittest.mock import patch sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) import audit_reconciliation_mode as arm import control_plane_db as cpd import issue_lock_store import mcp_server import missing_worktree_reconcile as mwr import task_capability_map # Profiles mirroring the configured role split. The reconciler is the only role # the project grants cleanup authority to (#729 / #970 review 644 B2). RECONCILER_PROFILE = { "profile_name": "prgs-reconciler", "role": "reconciler", "role_kind": "reconciler", "allowed_operations": [ "gitea.read", "gitea.pr.close", "gitea.issue.comment", "gitea.branch.delete", ], "forbidden_operations": ["gitea.pr.merge", "gitea.pr.approve"], "audit_label": "prgs-reconciler", } REVIEWER_PROFILE = { "profile_name": "prgs-reviewer", "role": "reviewer", "role_kind": "reviewer", "allowed_operations": [ "gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.issue.comment", ], "forbidden_operations": ["gitea.branch.delete", "gitea.repo.commit"], "audit_label": "prgs-reviewer", } AUTHOR_WITH_DELETE_PROFILE = { # Holds the cleanup *permission* but not the cleanup *role*: permission # alone must never authorize the mutation. "profile_name": "prgs-author-delete", "role": "author", "role_kind": "author", "allowed_operations": [ "gitea.read", "gitea.branch.delete", "gitea.repo.commit", ], "forbidden_operations": [], "audit_label": "prgs-author-delete", } class _MissingWorktreeTestBase(unittest.TestCase): """Shared fixture: a real control-plane DB, git root, and lock dir.""" def setUp(self): self.temp_dir = tempfile.mkdtemp() self.db_path = os.path.join(self.temp_dir, "control_plane.sqlite3") self.db = cpd.ControlPlaneDB(self.db_path) self.project_root = os.path.join(self.temp_dir, "repo") os.makedirs(os.path.join(self.project_root, "branches"), exist_ok=True) # Initialize git repo in project_root for git rev-parse checks. # Argument list form: no shell, so a temp path can never be interpreted # as shell syntax. subprocess.run( ["git", "-C", self.project_root, "init", "-q"], check=True, capture_output=True, ) # Point the durable issue-lock store at an isolated directory so the # real lock reader runs against test data, not the operator's locks. self.lock_dir = os.path.join(self.temp_dir, "issue-locks") os.makedirs(self.lock_dir, exist_ok=True) self._prior_lock_dir = os.environ.get(issue_lock_store.LOCK_DIR_ENV) os.environ[issue_lock_store.LOCK_DIR_ENV] = self.lock_dir arm.clear_phase() def tearDown(self): arm.clear_phase() if self._prior_lock_dir is None: os.environ.pop(issue_lock_store.LOCK_DIR_ENV, None) else: os.environ[issue_lock_store.LOCK_DIR_ENV] = self._prior_lock_dir shutil.rmtree(self.temp_dir, ignore_errors=True) # ── helpers ────────────────────────────────────────────────────────── def _age_session_heartbeat(self, session_id, *, seconds=None): """Age a session's heartbeat past the staleness grace. ``assign_and_lease`` opens a session row whose heartbeat is fresh by construction, and nothing closes it on release. Real abandoned bindings — the ones #970 exists to retire — have long-stale heartbeats, so the fixture models that rather than testing against a session that is live only because the test just created it. """ age = seconds or (mwr.SESSION_HEARTBEAT_STALE_SECONDS + 3600) stale_ts = ( datetime.now(timezone.utc) - timedelta(seconds=age) ).replace(microsecond=0).isoformat().replace("+00:00", "Z") with self.db._tx() as conn: # noqa: SLF001 - fixture ages durable state conn.execute( "UPDATE sessions SET last_heartbeat_at = ? WHERE session_id = ?", (stale_ts, session_id), ) def _stale_lease( self, *, number=402, path=None, session_id="sess-402", org="org", repo="repo", ): """Create a released lease whose recorded worktree path is missing.""" stale_path = path or os.path.join(self.temp_dir, f"stale_missing_{number}") res = self.db.assign_and_lease( remote="prgs", org=org, repo=repo, kind="issue", number=number, role="author", session_id=session_id, worktree_path=stale_path, ) self.db.release_lease_recorded(res.lease_id, session_id=session_id) self._age_session_heartbeat(session_id) return res.lease_id, stale_path def _audit(self): return mwr.audit_missing_worktree_bindings( self.db, project_root=self.project_root, remote="prgs" ) def _candidate(self): audit = self._audit() candidates = audit["confirmed_stale_candidates"] self.assertTrue(candidates, "expected one confirmed stale candidate") return candidates[0] def _mint_cleanup_authorization(self, role="reconciler"): """Mint a genuine server-side cleanup authorization (#419 gate).""" arm.clear_phase() arm.enter_audit_phase("reconcile_merged_cleanups") phase = arm.authorize_cleanup_phase( operator_approved=True, delete_capability_proven=True, safety_proof={"safe_to_remove_worktree": True}, before_after_snapshot={ "before": "binding recorded with missing path", "after": "binding retired", }, ) self.assertTrue(phase["authorized"], phase) return mwr.assess_cleanup_authorization( role=role, capability_blockers=[], operator_authorized=False, task_capability_resolved=True, ) def _lease_row(self, lease_id): state = self.db.get_lease_workflow_state(lease_id) return dict(state["lease"]) def _set_lease_columns(self, lease_id, **columns): """Simulate a concurrent writer changing the lease between audit/apply.""" assignments = ", ".join(f"{k} = ?" for k in columns) params = list(columns.values()) + [lease_id] with self.db._tx() as conn: # noqa: SLF001 - deliberate race simulation conn.execute( f"UPDATE leases SET {assignments} WHERE lease_id = ?", params ) def _write_issue_lock(self, *, issue_number, worktree_path, branch, pid=None): """Write a durable issue lock through the production lock store.""" path = issue_lock_store.lock_file_path( remote="prgs", org="org", repo="repo", issue_number=issue_number, lock_dir=self.lock_dir, ) issue_lock_store.save_lock_file(path, { "issue_number": issue_number, "branch_name": branch, "worktree_path": worktree_path, "session_pid": pid if pid is not None else os.getpid(), "pid": pid if pid is not None else os.getpid(), "claimant": {"username": "jcwalker3", "profile": "prgs-author"}, }) return path class TestMissingWorktreeReconcile(_MissingWorktreeTestBase): """Original #970 acceptance criteria.""" def test_audit_missing_worktree_bindings_reports_associations(self): res = self.db.assign_and_lease( remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", kind="pr", number=795, expected_head_sha="head795", role="reviewer", session_id="reviewer-pr795-test-session", worktree_path="/tmp/nonexistent_wt_path_795", ) lease_id = res.lease_id self.assertIsNotNone(lease_id) # Release the lease (dead/released) self.db.release_lease_recorded(lease_id, session_id="reviewer-pr795-test-session") self._age_session_heartbeat("reviewer-pr795-test-session") audit = mwr.audit_missing_worktree_bindings( self.db, project_root=self.project_root, remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", ) self.assertTrue(audit["host_mount_healthy"]) self.assertEqual(audit["missing_count"], 1) self.assertEqual(audit["confirmed_stale_count"], 1) candidate = audit["confirmed_stale_candidates"][0] self.assertEqual(candidate["lease_id"], lease_id) self.assertEqual(candidate["work_number"], 795) self.assertEqual(candidate["session_id"], "reviewer-pr795-test-session") self.assertEqual(candidate["classification"], mwr.CLASS_CONFIRMED_STALE_DELETED) self.assertTrue(candidate["retire_eligible"]) def test_unavailable_host_mount_blocks_retirement(self): # Point to a non-existent project_root bad_root = os.path.join(self.temp_dir, "nonexistent_repo_dir") audit = mwr.audit_missing_worktree_bindings( self.db, project_root=bad_root, remote="prgs", ) self.assertFalse(audit["host_mount_healthy"]) res = self.db.assign_and_lease( remote="prgs", org="org", repo="repo", kind="issue", number=100, role="author", session_id="sess-100", worktree_path="/tmp/missing_100", ) self.db.release_lease_recorded(res.lease_id, session_id="sess-100") self._age_session_heartbeat("sess-100") audit2 = mwr.audit_missing_worktree_bindings(self.db, project_root=bad_root, remote="prgs") self.assertEqual(audit2["unavailable_host_count"], 1) self.assertEqual(audit2["confirmed_stale_count"], 0) self.assertFalse(audit2["missing_bindings"][0]["retire_eligible"]) def test_live_lease_and_live_session_block_retirement(self): self.db.assign_and_lease( remote="prgs", org="org", repo="repo", kind="issue", number=200, role="author", session_id="sess-live-200", worktree_path="/tmp/missing_live_200", owner_pid=os.getpid(), ) audit = mwr.audit_missing_worktree_bindings(self.db, project_root=self.project_root, remote="prgs") self.assertEqual(audit["live_protected_count"], 1) self.assertEqual(audit["confirmed_stale_count"], 0) # Attempting resolution on a live protected binding is refused. c = audit["missing_bindings"][0] res_mut = mwr.resolve_missing_worktree_binding( self.db, binding=c, dry_run=False, cleanup_authorization=self._mint_cleanup_authorization(), project_root=self.project_root, ) self.assertFalse(res_mut["success"]) self.assertEqual(res_mut["reason"], "revalidation_failed") def test_moved_worktree_path_blocks_retirement(self): # Create a worktree under branches/ to simulate moved path moved_dir = os.path.join(self.project_root, "branches", "issue-635-project-registry-api") os.makedirs(moved_dir, exist_ok=True) res = self.db.assign_and_lease( remote="prgs", org="org", repo="repo", kind="issue", number=635, role="author", session_id="sess-635", worktree_path="/tmp/old_missing_path_635", ) self.db.release_lease_recorded(res.lease_id, session_id="sess-635") self._age_session_heartbeat("sess-635") cls_info = mwr.classify_worktree_binding( recorded_path="/tmp/old_missing_path_635", branch="issue-635-project-registry-api", project_root=self.project_root, ) self.assertEqual(cls_info["classification"], mwr.CLASS_MOVED_WORKTREE) self.assertFalse(cls_info["retire_eligible"]) self.assertEqual(os.path.realpath(cls_info["moved_to_path"]), os.path.realpath(moved_dir)) def test_recreation_race_revalidation_blocks_mutation(self): path = os.path.join(self.temp_dir, "recreated_wt") lease_id, _ = self._stale_lease(number=300, path=path, session_id="sess-300") # Initial audit when path is missing candidate = self._candidate() # Simulate concurrent recreation of worktree path os.makedirs(path, exist_ok=True) # Pre-mutation revalidation detects recreation and blocks res_mut = mwr.resolve_missing_worktree_binding( self.db, binding=candidate, dry_run=False, cleanup_authorization=self._mint_cleanup_authorization(), project_root=self.project_root, ) self.assertFalse(res_mut["success"]) self.assertEqual(res_mut["reason"], "revalidation_failed") self.assertEqual(self._lease_row(lease_id)["worktree_path"], path) def test_authorized_cleanup_retires_stale_binding_and_is_idempotent(self): # Create 2 leases: 1 stale missing, 1 valid existing worktree valid_path = os.path.join(self.project_root, "branches", "valid_wt") os.makedirs(valid_path, exist_ok=True) unrelated_marker = os.path.join(valid_path, "keep-me.txt") with open(unrelated_marker, "w", encoding="utf-8") as handle: handle.write("unrelated worktree content") stale_path = os.path.join(self.temp_dir, "stale_missing_400") res1 = self.db.assign_and_lease( remote="prgs", org="org", repo="repo", kind="issue", number=401, role="author", session_id="sess-401", worktree_path=valid_path, ) res2 = self.db.assign_and_lease( remote="prgs", org="org", repo="repo", kind="issue", number=402, role="author", session_id="sess-402", worktree_path=stale_path, ) self.db.release_lease_recorded(res2.lease_id, session_id="sess-402") self._age_session_heartbeat("sess-402") # Dry-run reconciliation dry = mwr.reconcile_missing_worktree_bindings( self.db, project_root=self.project_root, remote="prgs", dry_run=True ) self.assertTrue(dry["success"]) self.assertTrue(dry["dry_run"]) self.assertEqual(dry["stale_candidates_count"], 1) # Dry-run mutated nothing. self.assertEqual( self._lease_row(res2.lease_id)["worktree_path"], stale_path ) # Apply reconciliation with a genuine server-minted authorization applied = mwr.reconcile_missing_worktree_bindings( self.db, project_root=self.project_root, remote="prgs", dry_run=False, cleanup_authorization=self._mint_cleanup_authorization(), ) self.assertTrue(applied["success"]) self.assertFalse(applied["dry_run"]) self.assertTrue(applied["worktrees_dimension_resolved"]) self.assertEqual(applied["after_audit"]["missing_count"], 0) # Verify DB: lease 2 worktree_path is cleared, lease 1 worktree_path remains intact l1 = self.db.get_lease_workflow_state(res1.lease_id) self.assertEqual(l1["lease"]["worktree_path"], valid_path) l2 = self.db.get_lease_workflow_state(res2.lease_id) self.assertEqual(l2["lease"]["worktree_path"], "") prov = json.loads(l2["lease"]["provenance_json"]) self.assertTrue(prov.get("worktree_path_retired")) self.assertEqual(prov.get("retired_worktree_path"), stale_path) # Unrelated worktree contents and git metadata are untouched. self.assertTrue(os.path.isfile(unrelated_marker)) self.assertTrue(os.path.isdir(os.path.join(self.project_root, ".git"))) # Re-run (idempotency check) rerun = mwr.reconcile_missing_worktree_bindings( self.db, project_root=self.project_root, remote="prgs", dry_run=False, cleanup_authorization=self._mint_cleanup_authorization(), ) self.assertTrue(rerun["success"]) self.assertTrue(rerun["worktrees_dimension_resolved"]) self.assertEqual(rerun["stale_candidates_count"], 0) def test_historical_post_pr968_records_resolution(self): # Create historical records matching #970 description res_795 = self.db.assign_and_lease( remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", kind="pr", number=795, expected_head_sha="head795hist", role="reviewer", session_id="reviewer-pr795-hist", worktree_path="/Users/jasonwalker/Development/Gitea-Tools/branches/review-feat-issue-628-autonomous-handoffs-orchestration", ) self.db.release_lease_recorded(res_795.lease_id, session_id="reviewer-pr795-hist") self._age_session_heartbeat("reviewer-pr795-hist") res_635 = self.db.assign_and_lease( remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", kind="issue", number=635, role="author", session_id="author-issue635-hist", worktree_path="/Users/jasonwalker/Development/Gitea-Tools/branches/issue-635-project-registry-api", ) self.db.release_lease_recorded(res_635.lease_id, session_id="author-issue635-hist") self._age_session_heartbeat("author-issue635-hist") # Audit finds both historical records audit = mwr.audit_missing_worktree_bindings(self.db, project_root=self.project_root, remote="prgs") self.assertEqual(audit["confirmed_stale_count"], 2) # Reconcile safely resolves both rec = mwr.reconcile_missing_worktree_bindings( self.db, project_root=self.project_root, remote="prgs", dry_run=False, cleanup_authorization=self._mint_cleanup_authorization(), ) self.assertTrue(rec["worktrees_dimension_resolved"]) self.assertEqual(rec["after_audit"]["missing_count"], 0) class TestB1StaleSnapshotRevalidation(_MissingWorktreeTestBase): """B1: safety-relevant drift between audit and apply must fail closed.""" def _apply(self, candidate): return mwr.resolve_missing_worktree_binding( self.db, binding=candidate, dry_run=False, cleanup_authorization=self._mint_cleanup_authorization(), project_root=self.project_root, ) def test_lease_reactivated_between_audit_and_apply_fails_closed(self): """The reviewer's exact probe: released at audit, active at apply.""" lease_id, stale_path = self._stale_lease(number=501, session_id="sess-501") candidate = self._candidate() # Concurrent writer re-activates the same lease with a live process. self._set_lease_columns(lease_id, status="active", owner_pid=os.getpid()) result = self._apply(candidate) self.assertFalse(result["success"]) self.assertFalse(result["performed"]) self.assertEqual(result["reason"], "safety_state_changed") self.assertIn("lease_status", result["changed_fields"]) # Nothing was mutated. self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) def test_lease_owner_session_change_between_audit_and_apply_fails_closed(self): lease_id, stale_path = self._stale_lease(number=502, session_id="sess-502") candidate = self._candidate() self.db.upsert_session( session_id="someone-else-503", role="author", profile="prgs-author", pid=os.getpid(), ) self._set_lease_columns(lease_id, session_id="someone-else-503") result = self._apply(candidate) self.assertFalse(result["success"]) self.assertEqual(result["reason"], "safety_state_changed") self.assertIn("lease_session_id", result["changed_fields"]) self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) def test_lease_owner_pid_change_between_audit_and_apply_fails_closed(self): lease_id, stale_path = self._stale_lease(number=504, session_id="sess-504") candidate = self._candidate() self._set_lease_columns(lease_id, owner_pid=999_999_999) result = self._apply(candidate) self.assertFalse(result["success"]) self.assertEqual(result["reason"], "safety_state_changed") self.assertIn("lease_owner_pid", result["changed_fields"]) self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) def test_binding_path_change_between_audit_and_apply_fails_closed(self): lease_id, _ = self._stale_lease(number=505, session_id="sess-505") candidate = self._candidate() moved_target = os.path.join(self.temp_dir, "relocated_missing_505") self._set_lease_columns(lease_id, worktree_path=moved_target) result = self._apply(candidate) self.assertFalse(result["success"]) self.assertEqual(result["reason"], "safety_state_changed") self.assertIn("binding_path", result["changed_fields"]) self.assertEqual(self._lease_row(lease_id)["worktree_path"], moved_target) def test_live_session_appearing_after_audit_fails_closed(self): lease_id, stale_path = self._stale_lease(number=506, session_id="sess-506") candidate = self._candidate() # A session row for the owning session appears (or revives) after audit. self.db.upsert_session( session_id="sess-506", role="author", profile="prgs-author", pid=os.getpid(), ) result = self._apply(candidate) self.assertFalse(result["success"]) self.assertEqual(result["reason"], "safety_state_changed") self.assertTrue( {"session_live", "session_active", "session_status", "session_pid_alive"} & set(result["changed_fields"]) ) self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) def test_binding_without_audited_snapshot_is_refused(self): """A caller cannot skip the drift check by omitting the snapshot.""" lease_id, stale_path = self._stale_lease(number=507, session_id="sess-507") candidate = dict(self._candidate()) candidate.pop("audited_state", None) result = self._apply(candidate) self.assertFalse(result["success"]) self.assertEqual(result["reason"], "safety_state_changed") self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) def test_revalidation_reads_live_rows_not_audit_snapshot(self): """read_live_safety_state must come from the DB, not the candidate.""" lease_id, _ = self._stale_lease(number=508, session_id="sess-508") candidate = dict(self._candidate()) # Lie in the snapshot: claim a status the DB never held. candidate["lease_status"] = "active" live = mwr.read_live_safety_state(self.db, candidate) self.assertEqual(live["snapshot"]["lease_status"], "released") self.assertEqual(live["lease_row"]["lease_id"], lease_id) class TestB2ServerEnforcedCleanupAuthorization(_MissingWorktreeTestBase): """B2: authorization is a server artifact, never a client boolean.""" def test_operator_authorized_boolean_is_rejected_by_module(self): self._stale_lease(number=601, session_id="sess-601") candidate = self._candidate() result = mwr.resolve_missing_worktree_binding( self.db, binding=candidate, dry_run=False, operator_authorized=True, project_root=self.project_root, ) self.assertFalse(result["success"]) self.assertEqual(result["reason"], "operator_authorized_rejected") def test_apply_without_authorization_is_refused(self): lease_id, stale_path = self._stale_lease(number=602, session_id="sess-602") candidate = self._candidate() result = mwr.resolve_missing_worktree_binding( self.db, binding=candidate, dry_run=False, cleanup_authorization=None, project_root=self.project_root, ) self.assertFalse(result["success"]) self.assertEqual(result["reason"], "authorization_required") self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) def test_forged_authorization_without_cleanup_phase_is_refused(self): """A hand-built authorization mapping cannot stand in for the gate.""" lease_id, stale_path = self._stale_lease(number=603, session_id="sess-603") candidate = self._candidate() forged = { "authorized": True, "source": mwr.CLEANUP_AUTHORIZATION_SOURCE, "role": "reconciler", "reasons": [], } arm.clear_phase() # the server never authorized a cleanup phase result = mwr.resolve_missing_worktree_binding( self.db, binding=candidate, dry_run=False, cleanup_authorization=forged, project_root=self.project_root, ) self.assertFalse(result["success"]) self.assertEqual(result["reason"], "authorization_required") self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) def test_authorization_requires_reconciler_role(self): arm.clear_phase() arm.enter_audit_phase("reconcile_merged_cleanups") arm.authorize_cleanup_phase( operator_approved=True, delete_capability_proven=True, safety_proof={"safe_to_remove_worktree": True}, before_after_snapshot={"before": "a", "after": "b"}, ) auth = mwr.assess_cleanup_authorization( role="author", capability_blockers=[], task_capability_resolved=True ) self.assertFalse(auth["authorized"]) self.assertTrue(any("reconciler" in r for r in auth["reasons"])) def test_authorization_requires_delete_capability(self): arm.clear_phase() arm.enter_audit_phase("reconcile_merged_cleanups") arm.authorize_cleanup_phase( operator_approved=True, delete_capability_proven=True, safety_proof={"safe_to_remove_worktree": True}, before_after_snapshot={"before": "a", "after": "b"}, ) auth = mwr.assess_cleanup_authorization( role="reconciler", capability_blockers=["profile forbids gitea.branch.delete"], task_capability_resolved=True, ) self.assertFalse(auth["authorized"]) def test_authorization_requires_resolved_task_capability(self): arm.clear_phase() arm.enter_audit_phase("reconcile_merged_cleanups") arm.authorize_cleanup_phase( operator_approved=True, delete_capability_proven=True, safety_proof={"safe_to_remove_worktree": True}, before_after_snapshot={"before": "a", "after": "b"}, ) auth = mwr.assess_cleanup_authorization( role="reconciler", capability_blockers=[], task_capability_resolved=False ) self.assertFalse(auth["authorized"]) self.assertTrue(any(mwr.CLEANUP_TASK in r for r in auth["reasons"])) def test_operator_authorized_rejected_even_for_authorized_reconciler(self): auth = mwr.assess_cleanup_authorization( role="reconciler", capability_blockers=[], operator_authorized=True, task_capability_resolved=True, ) self.assertFalse(auth["authorized"]) self.assertTrue(auth["operator_authorized_rejected"]) def test_cleanup_task_capability_is_reconciler_only(self): self.assertEqual( task_capability_map.required_role(mwr.CLEANUP_TASK), "reconciler" ) self.assertEqual( task_capability_map.required_permission(mwr.CLEANUP_TASK), mwr.CLEANUP_REQUIRED_PERMISSION, ) self.assertIn(mwr.CLEANUP_TASK, task_capability_map.ROLE_EXCLUSIVE_TASKS) class TestB2ProductionMcpBoundary(_MissingWorktreeTestBase): """B2 at the registered MCP tool — the real production entrypoint.""" CANONICAL_ORG = "Scaled-Tech-Consulting" CANONICAL_REPO = "Gitea-Tools" def _mcp_stale_lease(self, *, number, session_id): """A stale lease under the org/repo the tool resolves for 'prgs'.""" return self._stale_lease( number=number, session_id=session_id, org=self.CANONICAL_ORG, repo=self.CANONICAL_REPO, ) def _call_reconcile(self, **kwargs): # org/repo are passed explicitly: the 'prgs' remote defaults to the # Timesheet repo, so relying on resolution would audit a different # repository than the fixture wrote to. kwargs.setdefault("org", self.CANONICAL_ORG) kwargs.setdefault("repo", self.CANONICAL_REPO) with patch.object(mcp_server, "_control_plane_db_or_error", return_value=(self.db, [])), \ patch.object(mcp_server, "_canonical_local_git_root", return_value=self.project_root): return mcp_server.gitea_reconcile_missing_worktree_bindings(**kwargs) def test_production_tools_are_registered(self): for name in ( "gitea_audit_missing_worktree_bindings", "gitea_reconcile_missing_worktree_bindings", ): self.assertTrue( callable(getattr(mcp_server, name, None)), f"{name} is not registered in the production MCP module", ) @patch.object(mcp_server, "get_profile", return_value=REVIEWER_PROFILE) def test_reviewer_with_operator_authorized_true_is_rejected(self, _profile): lease_id, stale_path = self._mcp_stale_lease(number=701, session_id="sess-701") result = self._call_reconcile( dry_run=False, operator_authorized=True, remote="prgs" ) self.assertFalse(result["success"]) self.assertEqual(result["reason"], "operator_authorized_rejected") self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) @patch.object(mcp_server, "get_profile", return_value=REVIEWER_PROFILE) def test_reviewer_apply_without_cleanup_capability_is_rejected(self, _profile): lease_id, stale_path = self._mcp_stale_lease(number=702, session_id="sess-702") result = self._call_reconcile(dry_run=False, remote="prgs") self.assertFalse(result["success"]) self.assertEqual(result["reason"], "cleanup_authorization_required") self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) @patch.object(mcp_server, "get_profile", return_value=AUTHOR_WITH_DELETE_PROFILE) def test_wrong_role_with_delete_permission_is_rejected(self, _profile): """Permission alone must not authorize: the role gate must hold.""" lease_id, stale_path = self._mcp_stale_lease(number=703, session_id="sess-703") result = self._call_reconcile(dry_run=False, remote="prgs") self.assertFalse(result["success"]) self.assertEqual(result["reason"], "cleanup_authorization_required") self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) @patch.object(mcp_server, "get_profile", return_value=RECONCILER_PROFILE) def test_reconciler_apply_without_authorized_cleanup_phase_is_rejected(self, _profile): """Correct role and capability, but no minted cleanup phase.""" lease_id, stale_path = self._mcp_stale_lease(number=704, session_id="sess-704") arm.clear_phase() result = self._call_reconcile(dry_run=False, remote="prgs") self.assertFalse(result["success"]) self.assertEqual(result["reason"], "cleanup_authorization_required") self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) @patch.object(mcp_server, "get_profile", return_value=REVIEWER_PROFILE) def test_dry_run_is_allowed_and_non_mutating_for_read_profile(self, _profile): lease_id, stale_path = self._mcp_stale_lease(number=705, session_id="sess-705") result = self._call_reconcile(dry_run=True, remote="prgs") self.assertTrue(result["success"]) self.assertTrue(result["dry_run"]) self.assertEqual(result["stale_candidates_count"], 1) self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) @patch.object(mcp_server, "get_profile", return_value=RECONCILER_PROFILE) def test_authorized_reconciler_apply_retires_binding(self, _profile): lease_id, _ = self._mcp_stale_lease(number=706, session_id="sess-706") arm.clear_phase() arm.enter_audit_phase("reconcile_merged_cleanups") arm.authorize_cleanup_phase( operator_approved=True, delete_capability_proven=True, safety_proof={"safe_to_remove_worktree": True}, before_after_snapshot={"before": "bound", "after": "retired"}, ) result = self._call_reconcile(dry_run=False, remote="prgs") self.assertTrue(result["success"], result) self.assertTrue(result["worktrees_dimension_resolved"]) self.assertEqual(self._lease_row(lease_id)["worktree_path"], "") @patch.object(mcp_server, "get_profile", return_value=REVIEWER_PROFILE) def test_audit_tool_is_read_only(self, _profile): lease_id, stale_path = self._mcp_stale_lease(number=707, session_id="sess-707") with patch.object(mcp_server, "_control_plane_db_or_error", return_value=(self.db, [])), \ patch.object(mcp_server, "_canonical_local_git_root", return_value=self.project_root): audit = mcp_server.gitea_audit_missing_worktree_bindings( remote="prgs", org=self.CANONICAL_ORG, repo=self.CANONICAL_REPO ) self.assertEqual(audit["confirmed_stale_count"], 1) self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) class TestB3ExpectedPathCompareAndSwap(_MissingWorktreeTestBase): """B3: both retirement paths enforce the expected stored path.""" def _checkpoint(self, *, session_id="cp-sess", worktree_path, number=800): self.db.write_session_checkpoint( remote="prgs", org="org", repo="repo", session_id=session_id, role="author", work_kind="issue", work_number=number, worktree_path=worktree_path, branch=f"fix/issue-{number}-x", ) rows = self.db.list_session_checkpoints(session_id=session_id) self.assertEqual(len(rows), 1) return rows[0] def test_checkpoint_retirement_refuses_wrong_expected_path(self): """The reviewer's probe: wrong expected_path returned retired=True.""" missing = os.path.join(self.temp_dir, "cp_missing_801") row = self._checkpoint(worktree_path=missing, number=801) with self.assertRaises(cpd.ControlPlaneError): self.db.retire_session_checkpoint_worktree_path( session_id="cp-sess", checkpoint_id=row["checkpoint_id"], expected_path="/some/other/path", ) after = self.db.list_session_checkpoints(session_id="cp-sess")[0] self.assertEqual(after["worktree_path"], missing) def test_checkpoint_retirement_requires_expected_path(self): missing = os.path.join(self.temp_dir, "cp_missing_802") row = self._checkpoint(worktree_path=missing, number=802) with self.assertRaises(cpd.ControlPlaneError): self.db.retire_session_checkpoint_worktree_path( session_id="cp-sess", checkpoint_id=row["checkpoint_id"] ) after = self.db.list_session_checkpoints(session_id="cp-sess")[0] self.assertEqual(after["worktree_path"], missing) def test_checkpoint_retirement_refuses_unknown_row(self): with self.assertRaises(cpd.ControlPlaneError): self.db.retire_session_checkpoint_worktree_path( session_id="no-such-session", expected_path="/tmp/whatever" ) def test_checkpoint_retirement_succeeds_on_exact_path(self): missing = os.path.join(self.temp_dir, "cp_missing_803") row = self._checkpoint(worktree_path=missing, number=803) proof = self.db.retire_session_checkpoint_worktree_path( session_id="cp-sess", checkpoint_id=row["checkpoint_id"], expected_path=missing, ) self.assertTrue(proof["retired"]) after = self.db.list_session_checkpoints(session_id="cp-sess")[0] self.assertEqual(after["worktree_path"], "") # Idempotent: a second retirement is a no-op, not a failure. again = self.db.retire_session_checkpoint_worktree_path( session_id="cp-sess", checkpoint_id=row["checkpoint_id"], expected_path=missing, ) self.assertFalse(again["retired"]) self.assertTrue(again["already_retired"]) def test_lease_retirement_refuses_wrong_expected_path(self): lease_id, stale_path = self._stale_lease(number=804, session_id="sess-804") with self.assertRaises(cpd.ControlPlaneError): self.db.retire_lease_worktree_path( lease_id, expected_path="/not/the/stored/path" ) self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) def test_lease_retirement_refuses_changed_status(self): lease_id, stale_path = self._stale_lease(number=805, session_id="sess-805") with self.assertRaises(cpd.ControlPlaneError): self.db.retire_lease_worktree_path( lease_id, expected_path=stale_path, expected_status="active" ) self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) def test_lease_retirement_refuses_changed_session(self): lease_id, stale_path = self._stale_lease(number=806, session_id="sess-806") with self.assertRaises(cpd.ControlPlaneError): self.db.retire_lease_worktree_path( lease_id, expected_path=stale_path, expected_session_id="a-different-session", ) self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) def test_lease_retirement_requires_expected_path(self): lease_id, stale_path = self._stale_lease(number=807, session_id="sess-807") with self.assertRaises(cpd.ControlPlaneError): self.db.retire_lease_worktree_path(lease_id) self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) class TestB4OwnershipEvidence(_MissingWorktreeTestBase): """B4: live-session, trustworthy-owner, and issue-lock evidence.""" def test_active_lease_with_dead_pid_is_not_confirmed_stale(self): """Dead-PID evidence alone must not retire a lease still held.""" dead_pid = 999_999_999 self.db.assign_and_lease( remote="prgs", org="org", repo="repo", kind="issue", number=901, role="author", session_id="sess-901", worktree_path=os.path.join(self.temp_dir, "missing_901"), owner_pid=dead_pid, ) audit = self._audit() self.assertEqual(audit["confirmed_stale_count"], 0) self.assertEqual( audit["missing_bindings"][0]["classification"], mwr.CLASS_LIVE_LEASE_PROTECTED, ) def test_session_active_is_populated_from_the_sessions_table(self): self.db.assign_and_lease( remote="prgs", org="org", repo="repo", kind="issue", number=902, role="author", session_id="sess-902", worktree_path=os.path.join(self.temp_dir, "missing_902"), owner_pid=os.getpid(), ) evidence = mwr.collect_session_evidence(self.db) self.assertIn("sess-902", evidence) self.assertTrue(evidence["sess-902"]["session_active"]) self.assertTrue(evidence["sess-902"]["session_pid_alive"]) binding = self._audit()["missing_bindings"][0] self.assertTrue(binding["session_active"]) def test_live_session_blocks_cleanup_of_released_lease(self): lease_id, stale_path = self._stale_lease(number=903, session_id="sess-903") # The lease is released, but its session is live. self.db.upsert_session( session_id="sess-903", role="author", profile="prgs-author", pid=os.getpid(), ) audit = self._audit() self.assertEqual(audit["confirmed_stale_count"], 0) self.assertEqual( audit["missing_bindings"][0]["classification"], mwr.CLASS_LIVE_SESSION_PROTECTED, ) self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) def test_trusted_owner_evidence_without_live_process_blocks_cleanup(self): """Session recorded active, process gone: still a valid owner signal. No terminal lease contradicts the session record here (a checkpoint-style binding), so the recorded owner is the best evidence available and must block cleanup even though nothing is running. """ result = mwr.classify_worktree_binding( recorded_path=os.path.join(self.temp_dir, "missing_904"), owner_pid=999_999_999, session_id="sess-904", session_active=True, session_pid_alive=False, session_status="active", project_root=self.project_root, ) self.assertEqual(result["classification"], mwr.CLASS_TRUSTED_OWNER_PROTECTED) self.assertFalse(result["retire_eligible"]) def test_released_lease_outranks_a_lingering_active_session_row(self): """An explicit release is later and more specific than a session row. Session rows are opened on assignment and never closed on release, so treating one as protective would make every released lease permanently unretireable — the exact degradation #970 exists to clear. """ result = mwr.classify_worktree_binding( recorded_path=os.path.join(self.temp_dir, "missing_908"), lease_status="released", owner_pid=999_999_999, session_id="sess-908", session_active=True, session_pid_alive=False, session_status="active", project_root=self.project_root, ) self.assertEqual(result["classification"], mwr.CLASS_CONFIRMED_STALE_DELETED) self.assertTrue(result["retire_eligible"]) def test_checkpoint_binding_with_active_session_is_protected(self): """A checkpoint has no lease to contradict its owning session.""" missing = os.path.join(self.temp_dir, "cp_missing_909") self.db.write_session_checkpoint( remote="prgs", org="org", repo="repo", session_id="sess-909", role="author", work_kind="issue", work_number=909, worktree_path=missing, branch="fix/issue-909-x", ) self.db.upsert_session( session_id="sess-909", role="author", profile="prgs-author", pid=999_999_999, ) audit = self._audit() checkpoint_bindings = [ b for b in audit["missing_bindings"] if b.get("source") == "session_checkpoint" ] self.assertEqual(len(checkpoint_bindings), 1) self.assertEqual( checkpoint_bindings[0]["classification"], mwr.CLASS_TRUSTED_OWNER_PROTECTED, ) def test_conflicting_issue_lock_blocks_cleanup(self): stale_path = os.path.join(self.temp_dir, "missing_905") lease_id, _ = self._stale_lease( number=905, path=stale_path, session_id="sess-905" ) self._write_issue_lock( issue_number=905, worktree_path=stale_path, branch="fix/issue-905-x", ) audit = self._audit() self.assertEqual(audit["confirmed_stale_count"], 0) self.assertEqual( audit["missing_bindings"][0]["classification"], mwr.CLASS_ISSUE_LOCK_PROTECTED, ) self.assertEqual(self._lease_row(lease_id)["worktree_path"], stale_path) def test_issue_lock_missing_path_is_reported_but_never_retired(self): lock_path = os.path.join(self.temp_dir, "missing_906") self._write_issue_lock( issue_number=906, worktree_path=lock_path, branch="fix/issue-906-x" ) audit = self._audit() self.assertEqual(audit["issue_lock_missing_count"], 1) reported = audit["issue_lock_missing_bindings"][0] self.assertFalse(reported["retire_eligible"]) self.assertEqual(reported["issue_number"], 906) def test_issue_lock_with_dead_owner_does_not_block_unrelated_binding(self): """A lock is only protective while it is live: no over-blocking.""" stale_path = os.path.join(self.temp_dir, "missing_907") self._stale_lease(number=907, path=stale_path, session_id="sess-907") self._write_issue_lock( issue_number=907, worktree_path=os.path.join(self.temp_dir, "other_missing_907"), branch="fix/issue-907-other", pid=999_999_999, ) audit = self._audit() self.assertEqual(audit["confirmed_stale_count"], 1) self.assertEqual( audit["confirmed_stale_candidates"][0]["worktree_path"], stale_path ) if __name__ == "__main__": unittest.main()