fix(author): bootstrap recovery for dirty orphaned issue worktrees (#860) #861

Merged
sysadmin merged 6 commits from fix/issue-860-dirty-orphan-worktree-recovery into master 2026-07-24 05:52:24 -05:00
7 changed files with 200 additions and 40 deletions
Showing only changes of commit 0b60fd6557 - Show all commits
+32 -21
View File
@@ -725,13 +725,14 @@ def prepare_recovery_worktree(
) )
head = (probe.stdout or "").strip() head = (probe.stdout or "").strip()
if probe.returncode != 0 or head != remote_head: if probe.returncode != 0 or head != remote_head:
# Allow dirty recovery worktree after prior partial apply.
return { return {
"success": True, "success": False,
"created": False, "created": False,
"resumed": True, "resumed": True,
"head": head, "head": head,
"reasons": [], "reasons": [
f"resume recovery worktree HEAD '{head}' does not match expected remote HEAD '{remote_head}'"
],
} }
return { return {
"success": True, "success": True,
@@ -741,7 +742,8 @@ def prepare_recovery_worktree(
"reasons": [], "reasons": [],
} }
# Create detached-at-head worktree then force branch association carefully. # Create detached-at-head worktree. Do not run git checkout -B because
# the source worktree holds the branch name (Git exit 128).
add = git.run( add = git.run(
[ [
"git", "git",
@@ -761,19 +763,6 @@ def prepare_recovery_worktree(
f"git worktree add failed: {(add.stderr or add.stdout or '').strip()}" f"git worktree add failed: {(add.stderr or add.stdout or '').strip()}"
], ],
} }
# Create/update local branch pointer without moving source.
br = git.run(
["git", "checkout", "-B", branch_name],
cwd=recovery_worktree_path,
)
if br.returncode != 0:
return {
"success": False,
"created": True,
"reasons": [
f"branch checkout failed: {(br.stderr or br.stdout or '').strip()}"
],
}
return { return {
"success": True, "success": True,
"created": True, "created": True,
@@ -965,6 +954,27 @@ def run_dirty_orphan_recovery(
journal["source_still_present"] = os.path.isdir(source_worktree_path) journal["source_still_present"] = os.path.isdir(source_worktree_path)
save_journal(journal, journal_dir=journal_dir) save_journal(journal, journal_dir=journal_dir)
# #860 F4: Do NOT finalize session binding if conflicts remain.
if conflicts:
return {
"success": False,
"performed": True,
"outcome": CONFLICTS_PRESENT,
"reasons": [
"conflicts present: manual resolution required before session binding (fail closed)"
],
"conflicts": list(conflicts),
"recovery_worktree_path": recovery_worktree_path,
"source_worktree_path": source_worktree_path,
"source_frozen": True,
"journal": journal,
"evidence": {
"written_paths": written,
"conflict_state_path": conflict_state_path,
"accepted_head": expected_remote_head,
},
}
# Phase 5: bind session (optional for pure assessor tests). # Phase 5: bind session (optional for pure assessor tests).
pid = session_pid if session_pid is not None else os.getpid() pid = session_pid if session_pid is not None else os.getpid()
lock_record = build_recovery_lock_record( lock_record = build_recovery_lock_record(
@@ -992,7 +1002,9 @@ def run_dirty_orphan_recovery(
except Exception: except Exception:
prior_gen = None prior_gen = None
_ils.bind_session_lock( _ils.bind_session_lock(
lock_record, expected_generation=prior_gen lock_record,
expected_generation=prior_gen,
recovery_sanctioned=True,
) )
else: else:
lock_writer(lock_record) lock_writer(lock_record)
@@ -1013,13 +1025,12 @@ def run_dirty_orphan_recovery(
journal["complete"] = True journal["complete"] = True
save_journal(journal, journal_dir=journal_dir) save_journal(journal, journal_dir=journal_dir)
outcome = CONFLICTS_PRESENT if conflicts else RECOVERY_COMPLETED
return { return {
"success": True, "success": True,
"performed": True, "performed": True,
"outcome": outcome, "outcome": RECOVERY_COMPLETED,
"reasons": [], "reasons": [],
"conflicts": list(conflicts), "conflicts": [],
"recovery_worktree_path": recovery_worktree_path, "recovery_worktree_path": recovery_worktree_path,
"source_worktree_path": source_worktree_path, "source_worktree_path": source_worktree_path,
"source_frozen": True, "source_frozen": True,
+39 -6
View File
@@ -1450,7 +1450,7 @@ def verify_preflight_purity(
dirty_files = sorted( dirty_files = sorted(
_parse_porcelain_entries(_get_workspace_porcelain(workspace)) _parse_porcelain_entries(_get_workspace_porcelain(workspace))
) )
if dirty_files: if dirty_files and task != "commit_files":
raise RuntimeError( raise RuntimeError(
nwb.format_namespace_workspace_binding_error( nwb.format_namespace_workspace_binding_error(
role_kind=role, role_kind=role,
@@ -4460,8 +4460,6 @@ def gitea_recover_dirty_orphaned_issue_worktree(
observed_remote = (probe.stdout or "").strip().split()[0] observed_remote = (probe.stdout or "").strip().split()[0]
except Exception: except Exception:
observed_remote = "" observed_remote = ""
if not observed_remote:
observed_remote = (expected_remote_head or "").strip()
registered = False registered = False
try: try:
@@ -4482,6 +4480,41 @@ def gitea_recover_dirty_orphaned_issue_worktree(
src, project_root src, project_root
) )
competing_locks: list[dict] = []
try:
all_live = issue_lock_store.list_live_locks()
for l in all_live:
if l.get("issue_number") == issue_number:
wt = l.get("worktree_path")
if not wt or not issue_lock_store._same_realpath(wt, src):
competing_locks.append(l)
except Exception:
competing_locks = []
wf_active = False
wf_expired = True
try:
db, _ = _control_plane_db_or_error()
if db is not None:
active_leases_data = lease_lifecycle.list_active_leases(
db,
remote=remote if remote in REMOTES else remote,
org=o,
repo=r,
)
leases_list = active_leases_data.get("leases") or []
for l in leases_list:
if l.get("work_number") == issue_number and l.get("work_kind") == "issue":
fresh = l.get("freshness") or {}
if fresh.get("status") == "active":
wf_active = True
wf_expired = False
elif fresh.get("status") in ("expired", "stale_dead_process"):
wf_active = False
wf_expired = True
except Exception:
pass
assessment = dirty_orphan_worktree_recovery.assess_dirty_orphan_recovery( assessment = dirty_orphan_worktree_recovery.assess_dirty_orphan_recovery(
existing_lock, existing_lock,
issue_number=issue_number, issue_number=issue_number,
@@ -4500,10 +4533,10 @@ def gitea_recover_dirty_orphaned_issue_worktree(
observed_local_head=observed_local, observed_local_head=observed_local,
observed_remote_head=observed_remote, observed_remote_head=observed_remote,
observed_dirty_fingerprints=observed_fps, observed_dirty_fingerprints=observed_fps,
competing_live_locks=[], competing_live_locks=competing_locks,
competing_live_sessions=[], competing_live_sessions=[],
workflow_lease_active=False, workflow_lease_active=wf_active,
workflow_lease_expired=True, workflow_lease_expired=wf_expired,
canonical_repo_root=canonical_root, canonical_repo_root=canonical_root,
worktree_registered=registered, worktree_registered=registered,
current_pid=os.getpid(), current_pid=os.getpid(),
+2
View File
@@ -16,11 +16,13 @@ ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock
SOURCE_LOCK_ISSUE = "gitea_lock_issue" SOURCE_LOCK_ISSUE = "gitea_lock_issue"
SOURCE_LOCK_ADOPTION = "gitea_lock_issue_adoption" SOURCE_LOCK_ADOPTION = "gitea_lock_issue_adoption"
SOURCE_OPERATOR_OVERRIDE = "operator_override" SOURCE_OPERATOR_OVERRIDE = "operator_override"
SOURCE_RECOVER_DIRTY_ORPHANED = "gitea_recover_dirty_orphaned_issue_worktree"
SANCTIONED_LOCK_SOURCES = frozenset({ SANCTIONED_LOCK_SOURCES = frozenset({
SOURCE_LOCK_ISSUE, SOURCE_LOCK_ISSUE,
SOURCE_LOCK_ADOPTION, SOURCE_LOCK_ADOPTION,
SOURCE_OPERATOR_OVERRIDE, SOURCE_OPERATOR_OVERRIDE,
SOURCE_RECOVER_DIRTY_ORPHANED,
}) })
_OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE" _OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE"
+48 -2
View File
@@ -169,6 +169,7 @@ def bind_session_lock(
*, *,
expected_generation: int | None = None, expected_generation: int | None = None,
renewal_sanctioned: bool = False, renewal_sanctioned: bool = False,
recovery_sanctioned: bool = False,
) -> str: ) -> str:
"""Persist a keyed lock and bind it to the current process session. """Persist a keyed lock and bind it to the current process session.
@@ -213,7 +214,9 @@ def bind_session_lock(
try: try:
with _exclusive_file_lock(sentinel): with _exclusive_file_lock(sentinel):
existing = read_lock_file(path) existing = read_lock_file(path)
overwrite_block = assess_foreign_lock_overwrite(existing, record) overwrite_block = assess_foreign_lock_overwrite(
existing, record, recovery_sanctioned=recovery_sanctioned
)
if overwrite_block: if overwrite_block:
raise RuntimeError(overwrite_block) raise RuntimeError(overwrite_block)
lease_block = assess_same_issue_lease_conflict( lease_block = assess_same_issue_lease_conflict(
@@ -222,6 +225,7 @@ def bind_session_lock(
branch_name=str(record.get("branch_name") or ""), branch_name=str(record.get("branch_name") or ""),
worktree_path=str(record.get("worktree_path") or ""), worktree_path=str(record.get("worktree_path") or ""),
renewal_sanctioned=renewal_sanctioned, renewal_sanctioned=renewal_sanctioned,
recovery_sanctioned=recovery_sanctioned,
) )
if lease_block: if lease_block:
raise RuntimeError(lease_block) raise RuntimeError(lease_block)
@@ -517,6 +521,7 @@ def assess_same_issue_lease_conflict(
worktree_path: str, worktree_path: str,
operation_type: str = AUTHOR_ISSUE_WORK_LEASE, operation_type: str = AUTHOR_ISSUE_WORK_LEASE,
renewal_sanctioned: bool = False, renewal_sanctioned: bool = False,
recovery_sanctioned: bool = False,
now: datetime | None = None, now: datetime | None = None,
) -> str | None: ) -> str | None:
"""Return a fail-closed error when a competing live lease blocks acquisition. """Return a fail-closed error when a competing live lease blocks acquisition.
@@ -548,6 +553,8 @@ def assess_same_issue_lease_conflict(
existing_branch == branch_name existing_branch == branch_name
and _same_realpath(str(existing_worktree or ""), worktree_path) and _same_realpath(str(existing_worktree or ""), worktree_path)
) )
if recovery_sanctioned and existing_issue == issue_number and existing_branch == branch_name:
return None
if is_lease_expired(existing_lock, now=now): if is_lease_expired(existing_lock, now=now):
# #760 AC1/AC2: exact-owner renewal is a different disposition from # #760 AC1/AC2: exact-owner renewal is a different disposition from
# foreign takeover and is evaluated first. Before this, both branches # foreign takeover and is evaluated first. Before this, both branches
@@ -578,10 +585,26 @@ def assess_same_issue_lease_conflict(
) )
def _lock_claimant(lock: dict[str, Any] | None) -> dict[str, str]:
if not isinstance(lock, dict):
return {}
claimant = lock.get("claimant")
if not isinstance(claimant, dict):
lease = lock.get("work_lease")
claimant = lease.get("claimant") if isinstance(lease, dict) else None
if not isinstance(claimant, dict):
return {}
return {
"username": str(claimant.get("username") or ""),
"profile": str(claimant.get("profile") or ""),
}
def assess_foreign_lock_overwrite( def assess_foreign_lock_overwrite(
existing_lock: dict[str, Any] | None, existing_lock: dict[str, Any] | None,
incoming_lock: dict[str, Any], incoming_lock: dict[str, Any],
*, *,
recovery_sanctioned: bool = False,
now: datetime | None = None, now: datetime | None = None,
) -> str | None: ) -> str | None:
"""Block writes that would clobber an unrelated live lease on the same key.""" """Block writes that would clobber an unrelated live lease on the same key."""
@@ -596,8 +619,31 @@ def assess_foreign_lock_overwrite(
) )
if same_issue and same_branch and same_worktree: if same_issue and same_branch and same_worktree:
return None return None
if not is_lease_live(existing_lock, now=now):
existing_claimant = _lock_claimant(existing_lock)
incoming_claimant = _lock_claimant(incoming_lock)
same_claimant = (
bool(existing_claimant.get("username"))
and existing_claimant.get("username") == incoming_claimant.get("username")
and existing_claimant.get("profile") == incoming_claimant.get("profile")
)
if recovery_sanctioned and same_issue and same_branch and same_claimant:
return None return None
if not is_lease_live(existing_lock, now=now):
# #860 F8: A non-live or PID-less lock still blocks foreign overwrite
# unless same claimant or sanctioned reclaim is proven.
if not same_claimant and same_issue:
reclaim = assess_expired_lock_reclaim(existing_lock, now=now)
if not reclaim.get("reclaim_allowed"):
return (
"Refusing foreign overwrite of non-live issue lock "
f"(issue #{existing_lock.get('issue_number')}, owner '{existing_claimant.get('username')}') "
"without sanctioned reclaim proof (fail closed)"
)
return None
return ( return (
"Refusing to overwrite a live foreign issue lock " "Refusing to overwrite a live foreign issue lock "
f"(issue #{existing_lock.get('issue_number')}, " f"(issue #{existing_lock.get('issue_number')}, "
+10 -8
View File
@@ -43,19 +43,21 @@ repo_root="$(cd "$script_dir/.." && pwd)"
# Enforce issue-linked, traceable branch names (issue → branch → worktree → PR). # Enforce issue-linked, traceable branch names (issue → branch → worktree → PR).
if [[ "$allow_unlinked" -eq 0 ]]; then if [[ "$allow_unlinked" -eq 0 ]]; then
locked_branch=$(python3 -c " if [[ "$dry_run" -eq 0 ]] && [[ ! "$branch" =~ ^review/pr-[0-9]+-.+ ]]; then
locked_branch=$(python3 -c "
import sys import sys
sys.path.insert(0, '$repo_root') sys.path.insert(0, '$repo_root')
import issue_lock_store import issue_lock_store
print(issue_lock_store.resolve_locked_branch_for_session('$branch')) print(issue_lock_store.resolve_locked_branch_for_session('$branch'))
") ")
if [[ -z "$locked_branch" ]]; then if [[ -z "$locked_branch" ]]; then
echo "Error: No session issue lock is bound. Call gitea_lock_issue before branch creation (fail closed)." >&2 echo "Error: No session issue lock is bound. Call gitea_lock_issue before branch creation (fail closed)." >&2
exit 2 exit 2
fi fi
if [[ "$branch" != "$locked_branch" ]]; then if [[ "$branch" != "$locked_branch" ]]; then
echo "Error: Requested branch '$branch' does not match locked branch '$locked_branch' (fail closed)." >&2 echo "Error: Requested branch '$branch' does not match locked branch '$locked_branch' (fail closed)." >&2
exit 2 exit 2
fi
fi fi
if [[ "$branch" =~ ^(fix|feat|docs|chore)/issue-[0-9]+-.+ ]] \ if [[ "$branch" =~ ^(fix|feat|docs|chore)/issue-[0-9]+-.+ ]] \
+5
View File
@@ -486,6 +486,11 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
# merger lease (#763). # merger lease (#763).
_PREFLIGHT_TASK_TRANSITIONS = frozenset({ _PREFLIGHT_TASK_TRANSITIONS = frozenset({
("review_pr", "acquire_reviewer_pr_lease"), ("review_pr", "acquire_reviewer_pr_lease"),
("work_issue", "lock_issue"),
("work_issue", "recover_dirty_orphaned_issue_worktree"),
("work_issue", "gitea_recover_dirty_orphaned_issue_worktree"),
("work_issue", "commit_files"),
("work_issue", "gitea_commit_files"),
}) })
+64 -3
View File
@@ -305,7 +305,8 @@ class CrashSafeRecovery(unittest.TestCase):
local_head_contents={"c.py": b"local-base"}, local_head_contents={"c.py": b"local-base"},
remote_head_contents={"c.py": b"remote-changed"}, remote_head_contents={"c.py": b"remote-changed"},
) )
self.assertTrue(result["success"]) # #860 F4: session binding is NOT finalized while conflicts remain
self.assertFalse(result["success"])
self.assertEqual(result["outcome"], dorec.CONFLICTS_PRESENT) self.assertEqual(result["outcome"], dorec.CONFLICTS_PRESENT)
sidecar = os.path.join(self.recovery, "c.py.recovered-dirty") sidecar = os.path.join(self.recovery, "c.py.recovered-dirty")
self.assertTrue(os.path.isfile(sidecar)) self.assertTrue(os.path.isfile(sidecar))
@@ -403,10 +404,8 @@ class JournalSymlinkRefusal(unittest.TestCase):
fh.write("{}") fh.write("{}")
link = os.path.join(tmp, "link.json") link = os.path.join(tmp, "link.json")
os.symlink(real, link) os.symlink(real, link)
# Point journal path helper via env
key = "symlink-test" key = "symlink-test"
jdir = tmp jdir = tmp
# Craft path that is a symlink by saving then replacing
path = dorec._journal_path(key, journal_dir=jdir) path = dorec._journal_path(key, journal_dir=jdir)
with open(path, "w", encoding="utf-8") as fh: with open(path, "w", encoding="utf-8") as fh:
json.dump({"idempotency_key": key}, fh) json.dump({"idempotency_key": key}, fh)
@@ -418,5 +417,67 @@ class JournalSymlinkRefusal(unittest.TestCase):
shutil.rmtree(tmp, ignore_errors=True) shutil.rmtree(tmp, ignore_errors=True)
class RealGitMultiWorktreeIntegration(unittest.TestCase):
def setUp(self):
import subprocess
self.tmp = tempfile.mkdtemp(prefix="git-integration-")
self.repo = os.path.join(self.tmp, "repo")
os.makedirs(self.repo, exist_ok=True)
subprocess.run(["git", "init"], cwd=self.repo, check=True, capture_output=True)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=self.repo, check=True)
subprocess.run(["git", "config", "user.email", "[email protected]"], cwd=self.repo, check=True)
with open(os.path.join(self.repo, "init.txt"), "w") as fh:
fh.write("init")
subprocess.run(["git", "add", "."], cwd=self.repo, check=True)
subprocess.run(["git", "commit", "-m", "init"], cwd=self.repo, check=True)
branch = "fix/issue-999-test"
subprocess.run(["git", "branch", branch], cwd=self.repo, check=True)
self.branches = os.path.join(self.repo, "branches")
self.source = os.path.join(self.branches, "issue-999-test")
subprocess.run(["git", "worktree", "add", self.source, branch], cwd=self.repo, check=True)
self.dirty_path = os.path.join(self.source, "dirty.txt")
with open(self.dirty_path, "w") as fh:
fh.write("dirty-data")
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_prepare_recovery_worktree_detached_no_exit_128(self):
import subprocess
head_sha = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=self.repo, text=True).strip()
rec_wt = os.path.join(self.branches, "recovery-issue-999-test")
res = dorec.prepare_recovery_worktree(
canonical_repo_root=self.repo,
recovery_worktree_path=rec_wt,
branch_name="fix/issue-999-test",
remote_head=head_sha,
)
self.assertTrue(res["success"], res.get("reasons"))
self.assertTrue(os.path.isdir(rec_wt))
def test_real_lock_rebind_recovery_sanctioned(self):
lock_dir = os.path.join(self.tmp, "locks")
rec_wt = os.path.join(self.branches, "recovery-issue-999-test")
os.makedirs(rec_wt, exist_ok=True)
record = {
"remote": "prgs",
"org": "Example-Org",
"repo": "Example-Repo",
"issue_number": 999,
"branch_name": "fix/issue-999-test",
"worktree_path": rec_wt,
"claimant": {"username": "author-user", "profile": "prgs-author"},
}
record_src = dict(record)
record_src["worktree_path"] = self.source
issue_lock_store.bind_session_lock(record_src, lock_dir=lock_dir)
path = issue_lock_store.bind_session_lock(
record,
lock_dir=lock_dir,
recovery_sanctioned=True,
)
self.assertTrue(os.path.isfile(path))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()