- Fix module reloading bug in task capability router (F-1) - Harden journal persistence and pending creations crash window (F-3) - Implement dirty worktree and author commit recovery preservation (F-4) - Fail closed on missing identity, profile, or session parameters (F-5) - Fix branches root path traversal and symlink validation (F-6) - Enforce O_NOFOLLOW and symlink checking on transition locks (F-7) - Support common ancestor merge-base verification for base SHA (F-8) - Release transition lock on compensating recovery (F-10) - Thread journal_dir through recovery and fix guidance strings (F-11, F-12) - Fix unittest mock import in bootstrap test suite (F-13)
This commit is contained in:
@@ -8,6 +8,7 @@ import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import author_issue_bootstrap
|
||||
import task_capability_map
|
||||
@@ -22,6 +23,9 @@ def _concurrent_bootstrap_worker(args: tuple[str, int, str, str, str, str]) -> d
|
||||
expected_base_sha=master_sha,
|
||||
idempotency_key=key,
|
||||
lock_dir=lock_dir,
|
||||
owner_session="session-concurrent-test",
|
||||
active_identity="jcwalker3",
|
||||
active_profile="prgs-author",
|
||||
)
|
||||
|
||||
|
||||
@@ -71,6 +75,7 @@ class TestAuthorIssueBootstrap(unittest.TestCase):
|
||||
idempotency_key=key,
|
||||
remote="prgs",
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertTrue(res.get("success"), f"Bootstrap failed: {res}")
|
||||
self.assertFalse(res.get("replayed"))
|
||||
@@ -86,7 +91,7 @@ class TestAuthorIssueBootstrap(unittest.TestCase):
|
||||
self.assertIn(worktree_path, wt_list.stdout)
|
||||
|
||||
# Verify phase journal written
|
||||
journal = author_issue_bootstrap.load_phase_journal(key)
|
||||
journal = author_issue_bootstrap.load_phase_journal(key, journal_dir=self.lock_dir)
|
||||
self.assertIsNotNone(journal)
|
||||
self.assertTrue(journal.get("completed"))
|
||||
self.assertEqual(journal.get("current_phase"), author_issue_bootstrap.PHASE_7_TRANSITION_COMPLETED)
|
||||
@@ -99,6 +104,7 @@ class TestAuthorIssueBootstrap(unittest.TestCase):
|
||||
canonical_repo_root=self.repo_dir,
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertTrue(res1["success"], f"res1 failed: {res1}")
|
||||
self.assertFalse(res1.get("replayed"))
|
||||
@@ -109,6 +115,7 @@ class TestAuthorIssueBootstrap(unittest.TestCase):
|
||||
canonical_repo_root=self.repo_dir,
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertTrue(res2["success"], f"res2 failed: {res2}")
|
||||
self.assertTrue(res2.get("replayed"))
|
||||
@@ -122,6 +129,7 @@ class TestAuthorIssueBootstrap(unittest.TestCase):
|
||||
canonical_repo_root=self.repo_dir,
|
||||
expected_base_sha=stale_sha,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "stale_concurrency_pin")
|
||||
@@ -135,6 +143,7 @@ class TestAuthorIssueBootstrap(unittest.TestCase):
|
||||
canonical_repo_root=self.repo_dir,
|
||||
worktree_path=outside_path,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "path_outside_canonical_branches_root")
|
||||
@@ -156,6 +165,7 @@ class TestAuthorIssueBootstrap(unittest.TestCase):
|
||||
branch_name=branch,
|
||||
worktree_path=wt_path,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "preexisting_dirty_worktree")
|
||||
@@ -254,10 +264,10 @@ class TestAuthorIssueBootstrap(unittest.TestCase):
|
||||
# Pre-create the branch and worktree on disk to simulate partial state after crash
|
||||
subprocess.run(["git", "-C", self.repo_dir, "branch", branch, self.master_sha], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", self.repo_dir, "worktree", "add", wt_path, branch], check=True, capture_output=True)
|
||||
author_issue_bootstrap.save_phase_journal(journal)
|
||||
author_issue_bootstrap.save_phase_journal(journal, journal_dir=self.lock_dir)
|
||||
|
||||
# Now resume/replay the transition but simulate lock binding failure during Phase 6
|
||||
with unittest.mock.patch("issue_lock_store.bind_session_lock", side_effect=RuntimeError("Lock failure test")):
|
||||
with mock.patch("issue_lock_store.bind_session_lock", side_effect=RuntimeError("Lock failure test")):
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
@@ -265,6 +275,7 @@ class TestAuthorIssueBootstrap(unittest.TestCase):
|
||||
worktree_path=wt_path,
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
|
||||
self.assertFalse(res["success"])
|
||||
@@ -286,7 +297,7 @@ class TestAuthorIssueBootstrap(unittest.TestCase):
|
||||
subprocess.run(["git", "-C", self.repo_dir, "branch", preexisting_branch, self.master_sha], check=True, capture_output=True)
|
||||
|
||||
# Call bootstrap with simulated failure during Phase 6 (lock binding)
|
||||
with unittest.mock.patch("issue_lock_store.bind_session_lock", side_effect=RuntimeError("Simulated lock failure")):
|
||||
with mock.patch("issue_lock_store.bind_session_lock", side_effect=RuntimeError("Simulated lock failure")):
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
@@ -294,6 +305,7 @@ class TestAuthorIssueBootstrap(unittest.TestCase):
|
||||
worktree_path=wt_path,
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
|
||||
self.assertFalse(res["success"])
|
||||
@@ -313,6 +325,7 @@ class TestAuthorIssueBootstrap(unittest.TestCase):
|
||||
branch_name="fix/issue-850-param-a",
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertTrue(res1["success"])
|
||||
|
||||
@@ -323,6 +336,7 @@ class TestAuthorIssueBootstrap(unittest.TestCase):
|
||||
branch_name="fix/issue-850-param-b",
|
||||
idempotency_key=key,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
self.assertFalse(res2["success"])
|
||||
self.assertEqual(res2.get("reason_code"), "incompatible_idempotency_replay")
|
||||
@@ -336,12 +350,126 @@ class TestAuthorIssueBootstrap(unittest.TestCase):
|
||||
canonical_repo_root=self.repo_dir,
|
||||
expected_base_sha=stale_sha,
|
||||
lock_dir=self.lock_dir,
|
||||
owner_session="session-test-1234",
|
||||
)
|
||||
next_action = res.get("exact_next_action", "")
|
||||
self.assertNotIn("scripts/worktree-start", next_action)
|
||||
self.assertNotIn("git worktree add", next_action)
|
||||
self.assertNotIn("bash", next_action.lower())
|
||||
|
||||
def test_missing_owner_session_refusal(self):
|
||||
"""Finding D: Missing owner_session context fails closed with typed refusal and zero mutation."""
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
owner_session=None,
|
||||
lock_dir=self.lock_dir,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "missing_owner_session")
|
||||
self.assertIn("exact_next_action", res)
|
||||
|
||||
def test_symlink_lock_file_refusal(self):
|
||||
"""Finding C: BootstrapTransitionLock refuses to follow symlinks."""
|
||||
key = "test_symlink_lock_key"
|
||||
safe_key = "".join(c if c.isalnum() or c in ("-", "_", ".") else "_" for c in key)
|
||||
lock_path = os.path.join(self.lock_dir, f"{safe_key}.lock")
|
||||
target_file = os.path.join(self.tmp_dir, "fake_target")
|
||||
with open(target_file, "w") as f:
|
||||
f.write("target")
|
||||
os.symlink(target_file, lock_path)
|
||||
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
with author_issue_bootstrap.BootstrapTransitionLock(key, journal_dir=self.lock_dir):
|
||||
pass
|
||||
self.assertIn("symlink", str(ctx.exception).lower())
|
||||
|
||||
def test_lock_directory_escape_refusal(self):
|
||||
"""Finding C: BootstrapTransitionLock refuses keys that escape lock directory."""
|
||||
with mock.patch("os.path.abspath", return_value="/tmp/outside/evil_key.lock"):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
author_issue_bootstrap.BootstrapTransitionLock("key", journal_dir=self.lock_dir)
|
||||
self.assertIn("escapes", str(ctx.exception).lower())
|
||||
|
||||
def test_missing_active_identity_refusal(self):
|
||||
"""F-5: Missing active_identity parameter fails closed."""
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
owner_session="session-test-1234",
|
||||
active_identity=None,
|
||||
active_profile="prgs-author",
|
||||
lock_dir=self.lock_dir,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "missing_active_identity")
|
||||
|
||||
def test_missing_active_profile_refusal(self):
|
||||
"""F-5: Missing active_profile parameter fails closed."""
|
||||
res = author_issue_bootstrap.bootstrap_author_issue_worktree(
|
||||
issue_number=850,
|
||||
canonical_repo_root=self.repo_dir,
|
||||
owner_session="session-test-1234",
|
||||
active_identity="jcwalker3",
|
||||
active_profile=None,
|
||||
lock_dir=self.lock_dir,
|
||||
)
|
||||
self.assertFalse(res["success"])
|
||||
self.assertEqual(res.get("reason_code"), "missing_active_profile")
|
||||
|
||||
def test_dirty_worktree_preserved_during_recovery(self):
|
||||
"""F-4: Compensating recovery does not delete dirty worktree."""
|
||||
branch = "fix/issue-850-rec-dirty"
|
||||
wt_path = os.path.join(self.branches_dir, "fix-issue-850-rec-dirty")
|
||||
subprocess.run(["git", "-C", self.repo_dir, "worktree", "add", "-b", branch, wt_path], check=True, capture_output=True)
|
||||
dirty_file = os.path.join(wt_path, "dirty.txt")
|
||||
with open(dirty_file, "w") as f:
|
||||
f.write("uncommitted work")
|
||||
|
||||
journal = {
|
||||
"idempotency_key": "test_dirty_rec",
|
||||
"issue_number": 850,
|
||||
"branch_name": branch,
|
||||
"worktree_path": wt_path,
|
||||
"artifacts_created": {
|
||||
"worktree_dir_created": True,
|
||||
"worktree_registered": True,
|
||||
},
|
||||
"failure_reason": "test dirty recovery",
|
||||
}
|
||||
rec = author_issue_bootstrap.run_compensating_recovery(journal, self.repo_dir, journal_dir=self.lock_dir)
|
||||
self.assertTrue(os.path.exists(wt_path))
|
||||
self.assertIn(f"worktree_path_preserved_dirty:{wt_path}", rec["rolled_back"])
|
||||
|
||||
def test_branch_with_commits_preserved_during_recovery(self):
|
||||
"""F-4: Compensating recovery does not delete branch with author commits."""
|
||||
branch = "fix/issue-850-rec-commits"
|
||||
subprocess.run(["git", "-C", self.repo_dir, "branch", branch, self.master_sha], check=True, capture_output=True)
|
||||
# Add a commit on the branch
|
||||
wt_path = os.path.join(self.branches_dir, "fix-issue-850-rec-commits")
|
||||
subprocess.run(["git", "-C", self.repo_dir, "worktree", "add", wt_path, branch], check=True, capture_output=True)
|
||||
cfile = os.path.join(wt_path, "commit.txt")
|
||||
with open(cfile, "w") as f:
|
||||
f.write("author commit")
|
||||
subprocess.run(["git", "-C", wt_path, "add", "commit.txt"], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", wt_path, "commit", "-m", "author commit"], check=True, capture_output=True)
|
||||
subprocess.run(["git", "-C", self.repo_dir, "worktree", "remove", "--force", wt_path], check=True, capture_output=True)
|
||||
|
||||
journal = {
|
||||
"idempotency_key": "test_commits_rec",
|
||||
"issue_number": 850,
|
||||
"branch_name": branch,
|
||||
"resolved_base_sha": self.master_sha,
|
||||
"artifacts_created": {
|
||||
"branch_created": True,
|
||||
},
|
||||
"failure_reason": "test commit branch recovery",
|
||||
}
|
||||
rec = author_issue_bootstrap.run_compensating_recovery(journal, self.repo_dir, journal_dir=self.lock_dir)
|
||||
branch_check = subprocess.run(["git", "-C", self.repo_dir, "rev-parse", "--verify", branch], capture_output=True, text=True, check=False)
|
||||
self.assertEqual(branch_check.returncode, 0, "Branch with commits was deleted!")
|
||||
self.assertIn(f"branch_preserved_commits:{branch}", rec["rolled_back"])
|
||||
|
||||
def test_task_capability_map_integration(self):
|
||||
"""Verify task_capability_map has bootstrap_author_issue_worktree configured correctly."""
|
||||
self.assertEqual(task_capability_map.required_role("bootstrap_author_issue_worktree"), "author")
|
||||
@@ -352,3 +480,4 @@ class TestAuthorIssueBootstrap(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user