fix(reconcile): safely resolve worktree bindings whose paths are missing (Closes #970)
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""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)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import control_plane_db as cpd
|
||||
import lease_lifecycle
|
||||
import missing_worktree_reconcile as mwr
|
||||
|
||||
|
||||
class TestMissingWorktreeReconcile(unittest.TestCase):
|
||||
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
|
||||
os.system(f"git -C '{self.project_root}' init -q")
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
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):
|
||||
res = 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 live protected binding is refused
|
||||
c = audit["missing_bindings"][0]
|
||||
res_mut = mwr.resolve_missing_worktree_binding(self.db, binding=c, dry_run=False, operator_authorized=True, 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")
|
||||
|
||||
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")
|
||||
res = self.db.assign_and_lease(
|
||||
remote="prgs", org="org", repo="repo", kind="issue", number=300,
|
||||
role="author", session_id="sess-300", worktree_path=path,
|
||||
)
|
||||
self.db.release_lease_recorded(res.lease_id, session_id="sess-300")
|
||||
|
||||
# Initial audit when path is missing
|
||||
audit = mwr.audit_missing_worktree_bindings(self.db, project_root=self.project_root, remote="prgs")
|
||||
candidate = audit["confirmed_stale_candidates"][0]
|
||||
|
||||
# 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,
|
||||
operator_authorized=True,
|
||||
project_root=self.project_root,
|
||||
)
|
||||
self.assertFalse(res_mut["success"])
|
||||
self.assertEqual(res_mut["reason"], "revalidation_failed")
|
||||
|
||||
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)
|
||||
|
||||
stale_path = "/tmp/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")
|
||||
|
||||
# Dry-run reconciliation
|
||||
dry = mwr.reconcile_missing_worktree_bindings(
|
||||
self.db, project_root=self.project_root, remote="prgs", dry_run=True, operator_authorized=True
|
||||
)
|
||||
self.assertTrue(dry["success"])
|
||||
self.assertTrue(dry["dry_run"])
|
||||
self.assertEqual(dry["stale_candidates_count"], 1)
|
||||
|
||||
# Apply reconciliation
|
||||
applied = mwr.reconcile_missing_worktree_bindings(
|
||||
self.db, project_root=self.project_root, remote="prgs", dry_run=False, operator_authorized=True
|
||||
)
|
||||
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)
|
||||
|
||||
# Re-run (idempotency check)
|
||||
rerun = mwr.reconcile_missing_worktree_bindings(
|
||||
self.db, project_root=self.project_root, remote="prgs", dry_run=False, operator_authorized=True
|
||||
)
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
# 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, operator_authorized=True
|
||||
)
|
||||
self.assertTrue(rec["worktrees_dimension_resolved"])
|
||||
self.assertEqual(rec["after_audit"]["missing_count"], 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user