Files
Gitea-Tools/dirty_orphan_worktree_recovery.py
jcwalker3 0b60fd6557 Remediate PR #861 findings F1-F9 and TestWorktreeStart failures (#860)
- Fix F1: Prepare recovery worktree detached at remote_head without git checkout -B to avoid exit 128 when branch is held by source worktree
- Fix F2: Pass recovery_sanctioned=True in bind_session_lock and assess_same_issue_lease_conflict
- Fix F3: Add SOURCE_RECOVER_DIRTY_ORPHANED to SANCTIONED_LOCK_SOURCES
- Fix F4: Stop after Phase 4 dirty apply when conflicts exist; do not finalize session binding
- Fix F5: Dynamically query competing live locks and workflow leases in MCP server
- Fix F6: Fail closed on recovery worktree resume when HEAD does not match expected remote_head
- Fix F7: Fail closed on remote HEAD observation failure rather than copying expected_remote_head pin
- Fix F8: Enforce foreign overwrite protection requiring same claimant or sanctioned reclaim
- Fix F9: Add real multi-worktree integration tests for prepare_recovery_worktree and lock rebind
- Fix TestWorktreeStart: Bypass session lock check for dry-run and review/pr-* branches in scripts/worktree-start
2026-07-23 22:34:35 -05:00

1081 lines
38 KiB
Python

"""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:
return {
"success": False,
"created": False,
"resumed": True,
"head": head,
"reasons": [
f"resume recovery worktree HEAD '{head}' does not match expected remote HEAD '{remote_head}'"
],
}
return {
"success": True,
"created": False,
"resumed": True,
"head": head,
"reasons": [],
}
# 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(
[
"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()}"
],
}
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)
# #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).
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,
recovery_sanctioned=True,
)
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)
return {
"success": True,
"performed": True,
"outcome": RECOVERY_COMPLETED,
"reasons": [],
"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"),
}