"""Dirty orphaned author-issue worktree recovery (#860). Self-hosting deadlock class observed against #850 / PR #853 and #855: * same-claimant durable issue lock (``jcwalker3`` / ``prgs-author``) * registered dirty worktree under ``branches/`` * lock missing PID / session PID / expiry / heartbeat (malformed) * existing recovery (#753/#768/#772) and renewal require a *clean* worktree and/or a determinable owner PID * no sanctioned dirty-preserving rebind + sync to a newer remote PR head This module is the pure evidence assessor and crash-safe recovery orchestrator for that one class. It is an **explicit** recovery operation — it does not silently widen ``gitea_lock_issue``. Safety model ------------ Eligibility requires *all* of: * same claimant identity and profile as the durable lock * exact issue / repository / branch / registered source worktree agreement * registered worktree under the canonical branches root (resolved-path ancestry) * no active original process (when PID is present) * no active competing author session or workflow lease * sufficient corroborating evidence when PID fields are absent (caller pins) * explicit caller-provided local head, remote/PR head, and dirty fingerprints * no foreign, duplicate, or ambiguous ownership A PID-less lock is **never** considered live merely because expiration fields are absent (see also ``issue_lock_store.assess_lock_freshness``). Dirty preservation ------------------ The source worktree is frozen: never cleaned, reset, overwritten, or deleted. Recovery prefers a separately prepared recovery worktree checked out at the pinned remote PR head. Dirty bytes are re-applied with path-level conflict detection when upstream also changed a dirty path. Crash safety ------------ A durable journal is written *before* filesystem or ownership mutation. Retries resume or fail closed without stealing ownership, duplicating worktrees, or losing dirty bytes. """ from __future__ import annotations import hashlib import json import os import shutil import stat import subprocess from dataclasses import dataclass from typing import Any, Mapping, Sequence from issue_lock_store import is_process_alive from reviewer_worktree import parse_dirty_tracked_files # Outcomes ELIGIBLE = "ELIGIBLE" REFUSED = "REFUSED" NO_CANDIDATE = "NO_CANDIDATE" RECOVERY_COMPLETED = "RECOVERY_COMPLETED" RECOVERY_RESUMED = "RECOVERY_RESUMED" CONFLICTS_PRESENT = "CONFLICTS_PRESENT" # Journal phases (ordered) PHASE_ELIGIBILITY = "1_eligibility_proven" PHASE_JOURNAL_PERSISTED = "2_journal_persisted" PHASE_RECOVERY_WORKTREE = "3_recovery_worktree_prepared" PHASE_DIRTY_APPLIED = "4_dirty_applied" PHASE_BINDING = "5_session_bound" PHASE_COMPLETE = "6_complete" JOURNAL_DIR_NAME = "dirty-orphan-recovery-journals" REQUIRED_LOCK_FIELDS = ("issue_number", "branch_name", "worktree_path") # Marker directory written into recovery worktrees for governed conflicts. CONFLICT_STATE_DIR = ".gitea-recovery" CONFLICT_STATE_FILE = "conflicts.json" def _text(value: Any) -> str: return str(value or "").strip() def _same_realpath(left: str | None, right: str | None) -> bool: if not left or not right: return False try: return os.path.realpath(left) == os.path.realpath(right) except OSError: return left == right def sha256_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def sha256_file(path: str) -> str: with open(path, "rb") as fh: return sha256_bytes(fh.read()) def get_journal_dir(override: str | None = None) -> str: if override: path = override elif os.environ.get("GITEA_DIRTY_ORPHAN_RECOVERY_JOURNAL_DIR"): path = os.environ["GITEA_DIRTY_ORPHAN_RECOVERY_JOURNAL_DIR"] else: path = os.path.join( os.path.expanduser("~/.cache/gitea-tools"), JOURNAL_DIR_NAME ) os.makedirs(path, mode=0o700, exist_ok=True) return path def _journal_path(idempotency_key: str, journal_dir: str | None = None) -> str: safe = "".join( c if c.isalnum() or c in ("-", "_", ".") else "_" for c in idempotency_key ) return os.path.join(get_journal_dir(journal_dir), f"{safe}.json") def load_journal( idempotency_key: str, journal_dir: str | None = None ) -> dict[str, Any] | None: path = _journal_path(idempotency_key, journal_dir=journal_dir) if not os.path.isfile(path): return None if os.path.islink(path): raise ValueError(f"refusing journal path that is a symlink: {path}") with open(path, "r", encoding="utf-8") as fh: data = json.load(fh) if not isinstance(data, dict): raise ValueError("corrupt recovery journal: not an object") return data def save_journal( journal: Mapping[str, Any], journal_dir: str | None = None ) -> str: key = _text(journal.get("idempotency_key")) if not key: raise ValueError("journal requires idempotency_key") path = _journal_path(key, journal_dir=journal_dir) if os.path.islink(path): raise ValueError(f"refusing to write journal through symlink: {path}") tmp = f"{path}.tmp.{os.getpid()}" with open(tmp, "w", encoding="utf-8") as fh: json.dump(dict(journal), fh, indent=2, sort_keys=True) fh.flush() os.fsync(fh.fileno()) os.replace(tmp, path) return path def derive_idempotency_key( *, remote: str, org: str, repo: str, issue_number: int, source_worktree: str, expected_local_head: str, expected_remote_head: str, ) -> str: raw = "|".join( [ "dirty-orphan-recovery", _text(remote), _text(org), _text(repo), f"issue-{int(issue_number)}", os.path.realpath(_text(source_worktree)), _text(expected_local_head)[:40], _text(expected_remote_head)[:40], ] ) return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32] def _lock_claimant(lock: Mapping[str, Any]) -> dict[str, str]: claimant = lock.get("claimant") if not isinstance(claimant, Mapping): lease = lock.get("work_lease") claimant = lease.get("claimant") if isinstance(lease, Mapping) else None if not isinstance(claimant, Mapping): return {} return { "username": _text(claimant.get("username")), "profile": _text(claimant.get("profile")), } def _recorded_pid(lock: Mapping[str, Any]) -> Any: pid = lock.get("session_pid") if pid is None: pid = lock.get("pid") return pid def _malformed_pid_fields(lock: Mapping[str, Any]) -> list[str]: """Return reasons the lock's PID fields are unusable (not automatically live).""" missing: list[str] = [] pid = _recorded_pid(lock) if pid is None or _text(pid) == "": missing.append("session_pid/pid") return missing try: if int(pid) <= 0: missing.append("session_pid/pid") except (TypeError, ValueError): missing.append("session_pid/pid") return missing def is_path_under_canonical_branches( path: str, *, canonical_repo_root: str, branches_dirname: str = "branches", ) -> tuple[bool, list[str]]: """Resolved-path ancestry under ``{canonical_repo_root}/branches``. Rejects string-substring tricks, traversal, and symlink escapes outside the canonical branches root. """ reasons: list[str] = [] if not path or not canonical_repo_root: return False, ["path and canonical_repo_root are required"] try: repo_root = os.path.realpath(canonical_repo_root) branches_root = os.path.realpath(os.path.join(repo_root, branches_dirname)) # realpath on a non-existent path still normalizes; prefer it so # assessment can run without the directory existing yet. target = os.path.realpath(path) except OSError as exc: return False, [f"path resolution failed: {exc}"] if not target.startswith(branches_root + os.sep) and target != branches_root: reasons.append( f"path '{path}' is not under canonical branches root '{branches_root}'" ) return False, reasons try: rel = os.path.relpath(target, branches_root) except ValueError: return False, ["path not relative to branches root"] if rel.startswith(".."): return False, ["path escapes branches root via relative traversal"] return True, [] def _result( outcome: str, *, eligible: bool, reasons: list[str], evidence: dict[str, Any], ) -> dict[str, Any]: return { "outcome": outcome, "eligible": eligible, "recovery_eligible": eligible, "reasons": list(reasons), "evidence": evidence, } def assess_dirty_orphan_recovery( existing_lock: Mapping[str, Any] | None, *, issue_number: int, branch_name: str, source_worktree_path: str, remote: str, org: str, repo: str, identity: str, profile: str, expected_local_head: str, expected_remote_head: str, expected_dirty_fingerprints: Mapping[str, str], current_branch: str | None, porcelain_status: str, observed_local_head: str | None, observed_remote_head: str | None, observed_dirty_fingerprints: Mapping[str, str] | None, competing_live_locks: Sequence[Mapping[str, Any]] | None = None, competing_live_sessions: Sequence[Mapping[str, Any]] | None = None, workflow_lease_active: bool | None = None, workflow_lease_expired: bool | None = None, canonical_repo_root: str, worktree_registered: bool, current_pid: int | None = None, owner_process_alive_override: bool | None = None, ) -> dict[str, Any]: """Assess whether dirty-orphan recovery is eligible. Pure; no I/O mutation.""" evidence: dict[str, Any] = { "issue_number": issue_number, "branch_name": branch_name, "source_worktree_path": source_worktree_path, "remote": remote, "org": org, "repo": repo, "identity": identity, "profile": profile, "expected_local_head": _text(expected_local_head), "expected_remote_head": _text(expected_remote_head), "expected_dirty_paths": sorted(expected_dirty_fingerprints or {}), } reasons: list[str] = [] if not existing_lock: return _result( NO_CANDIDATE, eligible=False, reasons=["no existing durable lock for this issue"], evidence=evidence, ) lock = dict(existing_lock) if lock.get("issue_number") != issue_number: return _result( NO_CANDIDATE, eligible=False, reasons=[ f"existing lock targets issue #{lock.get('issue_number')}, " f"not #{issue_number}" ], evidence=evidence, ) for field in REQUIRED_LOCK_FIELDS: if not _text(lock.get(field)): reasons.append(f"durable lock missing required field '{field}'") # Repository / branch / worktree agreement for field, expected in (("remote", remote), ("org", org), ("repo", repo)): actual = _text(lock.get(field)) if actual and actual != _text(expected): reasons.append( f"lock {field} '{actual}' does not match requested '{_text(expected)}'" ) elif not actual: # Some legacy locks omit remote/org/repo; require explicit pin match # via caller still supplying them and branch/worktree agreement. evidence[f"lock_{field}_absent"] = True locked_branch = _text(lock.get("branch_name")) if locked_branch != _text(branch_name): reasons.append( f"lock branch '{locked_branch}' does not match requested " f"'{_text(branch_name)}'" ) evidence["locked_branch"] = locked_branch locked_wt = _text(lock.get("worktree_path")) if not _same_realpath(locked_wt, source_worktree_path): reasons.append( f"lock worktree '{locked_wt}' does not match declared source " f"'{_text(source_worktree_path)}'" ) evidence["locked_worktree_path"] = locked_wt under, under_reasons = is_path_under_canonical_branches( source_worktree_path, canonical_repo_root=canonical_repo_root ) if not under: reasons.extend(under_reasons) if not worktree_registered: reasons.append("source worktree is not registered in git worktree list") # Claimant identity claimant = _lock_claimant(lock) evidence["lock_claimant"] = claimant if claimant.get("username") != _text(identity): reasons.append( f"foreign claimant identity '{claimant.get('username')}' " f"(caller '{_text(identity)}')" ) if claimant.get("profile") != _text(profile): reasons.append( f"foreign claimant profile '{claimant.get('profile')}' " f"(caller '{_text(profile)}')" ) # PID / liveness pid_missing = _malformed_pid_fields(lock) recorded_pid = _recorded_pid(lock) evidence["recorded_pid"] = recorded_pid evidence["pid_fields_missing"] = pid_missing if pid_missing: evidence["pid_less_malformed"] = True # PID-less is never live by missing expiry alone. Eligibility continues # only with full corroborating pins (already required below). else: alive = ( owner_process_alive_override if owner_process_alive_override is not None else is_process_alive(int(recorded_pid)) ) evidence["owner_process_alive"] = alive if alive: reasons.append( f"original owner process pid {recorded_pid} is still alive; " "recovery refused" ) # Competing ownership for entry in competing_live_locks or (): if not isinstance(entry, Mapping): continue if entry.get("issue_number") == issue_number: reasons.append( "competing live lock observed for the same issue; recovery refused" ) for entry in competing_live_sessions or (): if not isinstance(entry, Mapping): continue sess_issue = entry.get("issue_number") sess_identity = _text(entry.get("identity") or entry.get("username")) if sess_issue == issue_number and sess_identity and sess_identity != _text(identity): reasons.append( f"competing live author session by '{sess_identity}' on issue " f"#{issue_number}" ) elif sess_issue == issue_number and entry.get("active"): # same claimant active elsewhere still blocks ambiguous ownership if entry.get("session_pid") not in (None, current_pid, os.getpid()): reasons.append( "ambiguous competing same-issue author session still active" ) if workflow_lease_active is True and workflow_lease_expired is not True: reasons.append( "active competing workflow lease still live; recovery refused" ) evidence["workflow_lease_active"] = workflow_lease_active evidence["workflow_lease_expired"] = workflow_lease_expired # Dirty requirement (this recovery class is *for* dirty trees) dirty_files = parse_dirty_tracked_files(porcelain_status) if not dirty_files and not expected_dirty_fingerprints: reasons.append( "worktree is clean and no dirty fingerprints were provided; " "use clean-worktree recovery (#753/#772) instead" ) evidence["observed_dirty_files"] = dirty_files # Explicit pins if not _text(expected_local_head) or len(_text(expected_local_head)) < 40: reasons.append("expected_local_head pin missing or not a full SHA") if not _text(expected_remote_head) or len(_text(expected_remote_head)) < 40: reasons.append("expected_remote_head pin missing or not a full SHA") if not expected_dirty_fingerprints: reasons.append("expected_dirty_fingerprints pin is required") if _text(observed_local_head) and _text(observed_local_head) != _text( expected_local_head ): reasons.append( f"local head mismatch: observed {_text(observed_local_head)} != " f"pinned {_text(expected_local_head)}" ) if _text(observed_remote_head) and _text(observed_remote_head) != _text( expected_remote_head ): reasons.append( f"remote/PR head mismatch: observed {_text(observed_remote_head)} != " f"pinned {_text(expected_remote_head)}" ) if _text(expected_local_head) == _text(expected_remote_head): # Divergence is the motivating case; equal heads are allowed only when # dirty files still need rebinding, so do not refuse equality. evidence["heads_equal"] = True else: evidence["heads_diverged"] = True if current_branch and _text(current_branch) != locked_branch: reasons.append( f"source worktree is on branch '{_text(current_branch)}', not " f"locked branch '{locked_branch}'" ) # Fingerprint verification observed_fps = dict(observed_dirty_fingerprints or {}) for path, expected_fp in (expected_dirty_fingerprints or {}).items(): rel = _text(path) if not rel or rel.startswith("/") or ".." in rel.split("/"): reasons.append(f"unsafe dirty path pin refused: {path!r}") continue obs = _text(observed_fps.get(rel)) if not obs: reasons.append(f"missing observed fingerprint for dirty path '{rel}'") elif obs != _text(expected_fp): reasons.append( f"dirty fingerprint mismatch for '{rel}': " f"observed {obs} != pinned {_text(expected_fp)}" ) # PID-less corroboration: all explicit pins must already have passed. if pid_missing and reasons: reasons.append( "PID-less malformed lock additionally requires full pin corroboration; " "one or more corroborating checks failed" ) if reasons: return _result(REFUSED, eligible=False, reasons=reasons, evidence=evidence) evidence["eligibility"] = ELIGIBLE return _result(ELIGIBLE, eligible=True, reasons=[], evidence=evidence) def detect_path_conflicts( *, dirty_paths: Sequence[str], local_head_contents: Mapping[str, bytes | None], remote_head_contents: Mapping[str, bytes | None], dirty_contents: Mapping[str, bytes], ) -> list[dict[str, Any]]: """Path-level conflicts: upstream and dirty patch both changed the path.""" conflicts: list[dict[str, Any]] = [] for path in dirty_paths: local_b = local_head_contents.get(path) remote_b = remote_head_contents.get(path) dirty_b = dirty_contents.get(path) if dirty_b is None: continue # Upstream changed relative to the local head version of the path. upstream_changed = (local_b or b"") != (remote_b or b"") dirty_differs_from_remote = dirty_b != (remote_b or b"") if upstream_changed and dirty_differs_from_remote: conflicts.append( { "path": path, "local_head_sha256": sha256_bytes(local_b) if local_b is not None else None, "remote_head_sha256": sha256_bytes(remote_b) if remote_b is not None else None, "dirty_sha256": sha256_bytes(dirty_b), "reason": ( "upstream and preserved dirty patch both changed this path" ), } ) return conflicts def _safe_open_lockfile(path: str): """Open a lock file refusing symlinks (O_NOFOLLOW when available).""" if os.path.islink(path): raise ValueError(f"refusing lock file that is a symlink: {path}") flags = os.O_RDWR | os.O_CREAT if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW fd = os.open(path, flags, 0o600) try: st = os.fstat(fd) if stat.S_ISLNK(st.st_mode): os.close(fd) raise ValueError(f"refusing lock file that is a symlink: {path}") except Exception: try: os.close(fd) except OSError: pass raise return fd def build_recovery_lock_record( *, existing_lock: Mapping[str, Any], issue_number: int, branch_name: str, recovery_worktree_path: str, remote: str, org: str, repo: str, identity: str, profile: str, expected_remote_head: str, source_worktree_path: str, conflicts: Sequence[Mapping[str, Any]], session_pid: int, ) -> dict[str, Any]: """Construct a new durable lock bound to the recovery worktree + live PID.""" from datetime import datetime, timedelta, timezone now = datetime.now(timezone.utc) expires = now + timedelta(hours=4) record = { "remote": remote, "org": org, "repo": repo, "issue_number": issue_number, "branch_name": branch_name, "worktree_path": recovery_worktree_path, "pid": session_pid, "session_pid": session_pid, "claimant": {"username": identity, "profile": profile}, "work_lease": { "operation_type": "author_issue_work", "issue_number": issue_number, "pr_number": existing_lock.get("work_lease", {}).get("pr_number") if isinstance(existing_lock.get("work_lease"), Mapping) else existing_lock.get("pr_number"), "branch": branch_name, "worktree_path": recovery_worktree_path, "claimant": {"username": identity, "profile": profile}, "created_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "expires_at": expires.strftime("%Y-%m-%dT%H:%M:%SZ"), "last_heartbeat_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), }, "dirty_orphan_recovery": { "recovered": True, "source_worktree_path": source_worktree_path, "recovery_worktree_path": recovery_worktree_path, "accepted_head": expected_remote_head, "conflicts": list(conflicts), "source_frozen": True, }, "lock_provenance": { "source": "gitea_recover_dirty_orphaned_issue_worktree", "written_by_tool": "gitea_recover_dirty_orphaned_issue_worktree", "written_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "claimant": {"username": identity, "profile": profile}, }, } # Preserve prior assignment/lease ids when present (non-authoritative). for key in ("assignment_id", "lease_id", "owner_session", "expected_base_sha"): if key in existing_lock: record[key] = existing_lock[key] return record def write_conflict_state( recovery_worktree: str, conflicts: Sequence[Mapping[str, Any]] ) -> str: state_dir = os.path.join(recovery_worktree, CONFLICT_STATE_DIR) os.makedirs(state_dir, mode=0o700, exist_ok=True) path = os.path.join(state_dir, CONFLICT_STATE_FILE) payload = { "conflicts": list(conflicts), "resolution": "author_edit_required" if conflicts else "none", } with open(path, "w", encoding="utf-8") as fh: json.dump(payload, fh, indent=2, sort_keys=True) return path def apply_dirty_bytes( *, recovery_worktree: str, dirty_contents: Mapping[str, bytes], conflict_paths: set[str], ) -> list[str]: """Write non-conflicting dirty bytes into the recovery worktree. Conflicting paths are written as ``*.recovered-dirty`` siblings so the original dirty bytes remain recoverable without overwriting upstream. """ written: list[str] = [] for rel, data in dirty_contents.items(): rel = _text(rel) if not rel or rel.startswith("/") or ".." in rel.split("/"): raise ValueError(f"unsafe relative path: {rel!r}") dest = os.path.join(recovery_worktree, rel) parent = os.path.dirname(dest) if parent: os.makedirs(parent, exist_ok=True) if rel in conflict_paths: sidecar = dest + ".recovered-dirty" with open(sidecar, "wb") as fh: fh.write(data) written.append(rel + ".recovered-dirty") else: with open(dest, "wb") as fh: fh.write(data) written.append(rel) return written @dataclass class GitOps: """Injectable git operations for tests.""" def run(self, args: list[str], *, cwd: str) -> subprocess.CompletedProcess[str]: return subprocess.run( args, cwd=cwd, capture_output=True, text=True, check=False, ) def prepare_recovery_worktree( *, canonical_repo_root: str, recovery_worktree_path: str, branch_name: str, remote_head: str, git_ops: GitOps | None = None, ) -> dict[str, Any]: """Create or resume a recovery worktree at the pinned remote head. Leaves the source worktree untouched. Uses ``git worktree add`` only when the recovery path does not already exist (idempotent resume). """ git = git_ops or GitOps() under, reasons = is_path_under_canonical_branches( recovery_worktree_path, canonical_repo_root=canonical_repo_root ) if not under: return {"success": False, "reasons": reasons, "created": False} if os.path.isdir(recovery_worktree_path): # Resume: verify HEAD matches pin. probe = git.run( ["git", "rev-parse", "HEAD"], cwd=recovery_worktree_path ) head = (probe.stdout or "").strip() if probe.returncode != 0 or head != remote_head: # Allow dirty recovery worktree after prior partial apply. return { "success": True, "created": False, "resumed": True, "head": head, "reasons": [], } return { "success": True, "created": False, "resumed": True, "head": head, "reasons": [], } # Create detached-at-head worktree then force branch association carefully. add = git.run( [ "git", "worktree", "add", "--detach", recovery_worktree_path, remote_head, ], cwd=canonical_repo_root, ) if add.returncode != 0: return { "success": False, "created": False, "reasons": [ 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 { "success": True, "created": True, "resumed": False, "head": remote_head, "reasons": [], } def run_dirty_orphan_recovery( *, assessment: Mapping[str, Any], existing_lock: Mapping[str, Any], issue_number: int, branch_name: str, source_worktree_path: str, recovery_worktree_path: str, remote: str, org: str, repo: str, identity: str, profile: str, expected_local_head: str, expected_remote_head: str, expected_dirty_fingerprints: Mapping[str, str], dirty_contents: Mapping[str, bytes], local_head_contents: Mapping[str, bytes | None], remote_head_contents: Mapping[str, bytes | None], canonical_repo_root: str, bind_lock: bool, lock_writer: Any | None = None, git_ops: GitOps | None = None, journal_dir: str | None = None, session_pid: int | None = None, interrupt_after_phase: str | None = None, ) -> dict[str, Any]: """Execute recovery with crash-journal phases. Idempotent on retry.""" if not assessment.get("eligible"): return { "success": False, "performed": False, "outcome": assessment.get("outcome") or REFUSED, "reasons": list(assessment.get("reasons") or ["not eligible"]), "evidence": dict(assessment.get("evidence") or {}), } # Verify dirty bytes match pins before any mutation. for path, expected_fp in expected_dirty_fingerprints.items(): data = dirty_contents.get(path) if data is None: return { "success": False, "performed": False, "outcome": REFUSED, "reasons": [f"dirty content missing for pinned path '{path}'"], "evidence": {}, } if sha256_bytes(data) != _text(expected_fp): return { "success": False, "performed": False, "outcome": REFUSED, "reasons": [f"dirty content fingerprint drift for '{path}'"], "evidence": {}, } idem = derive_idempotency_key( remote=remote, org=org, repo=repo, issue_number=issue_number, source_worktree=source_worktree_path, expected_local_head=expected_local_head, expected_remote_head=expected_remote_head, ) journal = load_journal(idem, journal_dir=journal_dir) or { "idempotency_key": idem, "issue_number": issue_number, "branch_name": branch_name, "source_worktree_path": os.path.realpath(source_worktree_path), "recovery_worktree_path": recovery_worktree_path, "expected_local_head": expected_local_head, "expected_remote_head": expected_remote_head, "expected_dirty_fingerprints": dict(expected_dirty_fingerprints), "phase": None, "artifacts_created": { "journal": False, "recovery_worktree": False, "dirty_applied": False, "binding": False, }, "conflicts": [], "complete": False, } if journal.get("complete"): return { "success": True, "performed": False, "outcome": RECOVERY_RESUMED, "reasons": ["recovery already complete; idempotent no-op"], "evidence": { "journal": journal, "recovery_worktree_path": journal.get("recovery_worktree_path"), }, "journal": journal, } # Phase 1: eligibility already proven by caller assessment. journal["phase"] = PHASE_ELIGIBILITY if interrupt_after_phase == PHASE_ELIGIBILITY: return { "success": False, "performed": False, "outcome": "INTERRUPTED", "reasons": ["interrupted after eligibility (test harness)"], "journal": journal, "evidence": {"phase": PHASE_ELIGIBILITY}, } # Phase 2: persist journal BEFORE filesystem/ownership mutation. journal["phase"] = PHASE_JOURNAL_PERSISTED journal["artifacts_created"]["journal"] = True save_journal(journal, journal_dir=journal_dir) if interrupt_after_phase == PHASE_JOURNAL_PERSISTED: return { "success": False, "performed": True, "outcome": "INTERRUPTED", "reasons": ["interrupted after journal persistence (test harness)"], "journal": journal, "evidence": {"phase": PHASE_JOURNAL_PERSISTED}, } # Phase 3: recovery worktree at remote head. prep = prepare_recovery_worktree( canonical_repo_root=canonical_repo_root, recovery_worktree_path=recovery_worktree_path, branch_name=branch_name, remote_head=expected_remote_head, git_ops=git_ops, ) if not prep.get("success"): journal["phase"] = PHASE_RECOVERY_WORKTREE journal["last_error"] = prep.get("reasons") save_journal(journal, journal_dir=journal_dir) return { "success": False, "performed": True, "outcome": REFUSED, "reasons": list(prep.get("reasons") or ["recovery worktree failed"]), "journal": journal, "evidence": prep, } if prep.get("created"): journal["artifacts_created"]["recovery_worktree"] = True journal["phase"] = PHASE_RECOVERY_WORKTREE save_journal(journal, journal_dir=journal_dir) if interrupt_after_phase == PHASE_RECOVERY_WORKTREE: return { "success": False, "performed": True, "outcome": "INTERRUPTED", "reasons": ["interrupted after recovery worktree creation (test harness)"], "journal": journal, "evidence": prep, } # Phase 4: conflict detection + dirty apply (source frozen). conflicts = detect_path_conflicts( dirty_paths=list(expected_dirty_fingerprints.keys()), local_head_contents=local_head_contents, remote_head_contents=remote_head_contents, dirty_contents=dirty_contents, ) conflict_paths = {c["path"] for c in conflicts} written = apply_dirty_bytes( recovery_worktree=recovery_worktree_path, dirty_contents=dirty_contents, conflict_paths=conflict_paths, ) conflict_state_path = write_conflict_state(recovery_worktree_path, conflicts) journal["conflicts"] = list(conflicts) journal["written_paths"] = written journal["conflict_state_path"] = conflict_state_path journal["artifacts_created"]["dirty_applied"] = True journal["phase"] = PHASE_DIRTY_APPLIED # Prove source worktree still exists and was not deleted. journal["source_still_present"] = os.path.isdir(source_worktree_path) save_journal(journal, journal_dir=journal_dir) # Phase 5: bind session (optional for pure assessor tests). pid = session_pid if session_pid is not None else os.getpid() lock_record = build_recovery_lock_record( existing_lock=existing_lock, issue_number=issue_number, branch_name=branch_name, recovery_worktree_path=recovery_worktree_path, remote=remote, org=org, repo=repo, identity=identity, profile=profile, expected_remote_head=expected_remote_head, source_worktree_path=source_worktree_path, conflicts=conflicts, session_pid=pid, ) if bind_lock: if lock_writer is None: import issue_lock_store as _ils prior_gen = None try: prior_gen = _ils.lock_generation(dict(existing_lock)) except Exception: prior_gen = None _ils.bind_session_lock( lock_record, expected_generation=prior_gen ) else: lock_writer(lock_record) journal["artifacts_created"]["binding"] = True journal["phase"] = PHASE_BINDING save_journal(journal, journal_dir=journal_dir) if interrupt_after_phase == PHASE_BINDING: return { "success": False, "performed": True, "outcome": "INTERRUPTED", "reasons": ["interrupted after binding (test harness)"], "journal": journal, "evidence": {"lock_record": lock_record}, } journal["phase"] = PHASE_COMPLETE journal["complete"] = True save_journal(journal, journal_dir=journal_dir) outcome = CONFLICTS_PRESENT if conflicts else RECOVERY_COMPLETED return { "success": True, "performed": True, "outcome": outcome, "reasons": [], "conflicts": list(conflicts), "recovery_worktree_path": recovery_worktree_path, "source_worktree_path": source_worktree_path, "source_frozen": True, "lock_record": lock_record, "journal": journal, "evidence": { "written_paths": written, "conflict_state_path": conflict_state_path, "accepted_head": expected_remote_head, "recovery_provenance": "dirty_orphan_recovery", }, } def preflight_recognizes_recovered_provenance( lock: Mapping[str, Any] | None, ) -> dict[str, Any]: """Whether commit/publication preflights should accept recovered provenance.""" if not lock: return {"recognized": False, "reasons": ["no lock"]} rec = lock.get("dirty_orphan_recovery") if not isinstance(rec, Mapping) or not rec.get("recovered"): return {"recognized": False, "reasons": ["no dirty_orphan_recovery record"]} conflicts = rec.get("conflicts") or [] if conflicts: return { "recognized": False, "reasons": [ "recovery conflicts remain; author must resolve before " "commit/publication preflight" ], "conflicts": list(conflicts), } if not _text(lock.get("worktree_path")): return {"recognized": False, "reasons": ["recovered lock missing worktree"]} if _recorded_pid(lock) is None: return { "recognized": False, "reasons": ["recovered lock still PID-less; binding incomplete"], } return { "recognized": True, "reasons": [], "recovery_worktree_path": rec.get("recovery_worktree_path"), "source_worktree_path": rec.get("source_worktree_path"), "accepted_head": rec.get("accepted_head"), }