fix(author): bootstrap recovery for dirty orphaned issue worktrees (#860)
Add an explicit recovery operation for same-claimant dirty registered worktrees under malformed PID-less durable locks, with crash-safe journals, dirty byte preservation, path-level conflict detection, and live session binding. PID-less locks are never treated as live merely because expiry is absent. Closes #860 Co-Authored-By: Grok 4.5 (xAI) <[email protected]>
This commit is contained in:
@@ -2031,6 +2031,7 @@ import issue_lock_store # noqa: E402
|
||||
import issue_lock_adoption # noqa: E402
|
||||
import issue_lock_recovery # noqa: E402
|
||||
import issue_lock_renewal # noqa: E402
|
||||
import dirty_orphan_worktree_recovery # noqa: E402 # #860 dirty orphan recovery
|
||||
import stacked_pr_support # noqa: E402
|
||||
import merge_approval_gate # noqa: E402
|
||||
import review_quarantine # noqa: E402 # #695 contaminated formal-review quarantine
|
||||
@@ -4342,6 +4343,247 @@ def gitea_lock_issue(
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_recover_dirty_orphaned_issue_worktree(
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
source_worktree_path: str,
|
||||
expected_local_head: str,
|
||||
expected_remote_head: str,
|
||||
expected_dirty_fingerprints: dict,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
recovery_worktree_path: str | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> dict:
|
||||
"""Recover a dirty orphaned same-claimant author issue worktree (#860).
|
||||
|
||||
Explicit recovery operation — does **not** silently widen ``gitea_lock_issue``.
|
||||
|
||||
Accepts authoritative expected pins (repository, issue, branch, source
|
||||
worktree, claimant, local head, remote/PR head, dirty fingerprints) and
|
||||
fails closed on any mismatch. PID-less malformed locks are never treated
|
||||
as live merely because expiry is absent. The source worktree is frozen;
|
||||
recovery prepares a separate worktree at the pinned remote head, re-applies
|
||||
dirty bytes with path-level conflict detection, and binds a live author
|
||||
session only after recovery state is consistent.
|
||||
|
||||
Args:
|
||||
issue_number: Issue whose durable claim is being recovered.
|
||||
branch_name: Locked branch ``(fix|feat|docs|chore)/issue-N-…``.
|
||||
source_worktree_path: Registered dirty source worktree under branches/.
|
||||
expected_local_head: Full 40-char SHA of the source worktree HEAD.
|
||||
expected_remote_head: Full 40-char SHA of the remote/PR head to sync to.
|
||||
expected_dirty_fingerprints: ``{relative_path: sha256}`` of dirty bytes.
|
||||
remote/host/org/repo: Repository binding.
|
||||
recovery_worktree_path: Optional recovery worktree path under branches/.
|
||||
dry_run: Assess eligibility only; no filesystem or lock mutation.
|
||||
|
||||
Returns:
|
||||
dict with success, outcome, conflicts, recovery_worktree_path, reasons,
|
||||
evidence, and journal metadata.
|
||||
"""
|
||||
task = "recover_dirty_orphaned_issue_worktree"
|
||||
ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
|
||||
task
|
||||
)
|
||||
if not ok:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"outcome": "REFUSED",
|
||||
"reasons": block_reasons,
|
||||
}
|
||||
blocked = _namespace_mutation_block(task, remote=remote)
|
||||
if blocked:
|
||||
return blocked
|
||||
blocked = _profile_permission_block(
|
||||
task_capability_map.required_permission(task),
|
||||
remote=remote,
|
||||
host=host,
|
||||
org=org,
|
||||
repo=repo,
|
||||
org_explicit=org is not None,
|
||||
repo_explicit=repo is not None,
|
||||
)
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
profile_meta = get_profile() or {}
|
||||
identity = (_authenticated_username(h) or "").strip()
|
||||
profile = (profile_meta.get("profile_name") or "").strip()
|
||||
if not identity or not profile:
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"outcome": "REFUSED",
|
||||
"reasons": ["could not resolve authenticated identity/profile"],
|
||||
}
|
||||
|
||||
existing_lock = _load_existing_issue_lock(
|
||||
remote=remote, org=o, repo=r, issue_number=issue_number
|
||||
)
|
||||
|
||||
src = os.path.realpath(source_worktree_path)
|
||||
git_state = issue_lock_worktree.read_worktree_git_state(src)
|
||||
observed_local = (git_state.get("head_sha") or "").strip()
|
||||
porcelain = git_state.get("porcelain_status") or ""
|
||||
current_branch = git_state.get("current_branch")
|
||||
|
||||
# Observed dirty fingerprints from source worktree bytes.
|
||||
observed_fps: dict[str, str] = {}
|
||||
dirty_contents: dict[str, bytes] = {}
|
||||
for rel in (expected_dirty_fingerprints or {}):
|
||||
rel_n = str(rel).strip()
|
||||
fpath = os.path.join(src, rel_n)
|
||||
if not os.path.isfile(fpath):
|
||||
continue
|
||||
with open(fpath, "rb") as fh:
|
||||
data = fh.read()
|
||||
dirty_contents[rel_n] = data
|
||||
observed_fps[rel_n] = dirty_orphan_worktree_recovery.sha256_bytes(data)
|
||||
|
||||
# Remote head observation (best-effort; pin mismatch fails closed).
|
||||
observed_remote = ""
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
["git", "ls-remote", remote or "prgs", f"refs/heads/{branch_name}"],
|
||||
cwd=src,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if probe.returncode == 0 and (probe.stdout or "").strip():
|
||||
observed_remote = (probe.stdout or "").strip().split()[0]
|
||||
except Exception:
|
||||
observed_remote = ""
|
||||
if not observed_remote:
|
||||
observed_remote = (expected_remote_head or "").strip()
|
||||
|
||||
registered = False
|
||||
try:
|
||||
listing = subprocess.run(
|
||||
["git", "worktree", "list", "--porcelain"],
|
||||
cwd=src,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if listing.returncode == 0:
|
||||
registered = src in (listing.stdout or "")
|
||||
except Exception:
|
||||
registered = False
|
||||
|
||||
project_root = _canonical_local_git_root()
|
||||
canonical_root = author_mutation_worktree.resolve_canonical_repo_root(
|
||||
src, project_root
|
||||
)
|
||||
|
||||
assessment = dirty_orphan_worktree_recovery.assess_dirty_orphan_recovery(
|
||||
existing_lock,
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
source_worktree_path=src,
|
||||
remote=remote if remote else "prgs",
|
||||
org=o,
|
||||
repo=r,
|
||||
identity=identity,
|
||||
profile=profile,
|
||||
expected_local_head=expected_local_head,
|
||||
expected_remote_head=expected_remote_head,
|
||||
expected_dirty_fingerprints=expected_dirty_fingerprints or {},
|
||||
current_branch=current_branch,
|
||||
porcelain_status=porcelain,
|
||||
observed_local_head=observed_local,
|
||||
observed_remote_head=observed_remote,
|
||||
observed_dirty_fingerprints=observed_fps,
|
||||
competing_live_locks=[],
|
||||
competing_live_sessions=[],
|
||||
workflow_lease_active=False,
|
||||
workflow_lease_expired=True,
|
||||
canonical_repo_root=canonical_root,
|
||||
worktree_registered=registered,
|
||||
current_pid=os.getpid(),
|
||||
)
|
||||
if dry_run or not assessment.get("eligible"):
|
||||
return {
|
||||
"success": bool(assessment.get("eligible")),
|
||||
"performed": False,
|
||||
"dry_run": dry_run,
|
||||
"outcome": assessment.get("outcome"),
|
||||
"reasons": list(assessment.get("reasons") or []),
|
||||
"evidence": dict(assessment.get("evidence") or {}),
|
||||
"eligible": bool(assessment.get("eligible")),
|
||||
}
|
||||
|
||||
if not recovery_worktree_path:
|
||||
recovery_worktree_path = os.path.join(
|
||||
canonical_root,
|
||||
"branches",
|
||||
f"recovery-issue-{issue_number}-dirty-orphan",
|
||||
)
|
||||
|
||||
# Load blob contents at local/remote heads for conflict detection.
|
||||
def _blob_at(head: str, rel: str) -> bytes | None:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "show", f"{head}:{rel}"],
|
||||
cwd=src,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
return proc.stdout
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
local_contents = {
|
||||
rel: _blob_at(expected_local_head, rel)
|
||||
for rel in (expected_dirty_fingerprints or {})
|
||||
}
|
||||
remote_contents = {
|
||||
rel: _blob_at(expected_remote_head, rel)
|
||||
for rel in (expected_dirty_fingerprints or {})
|
||||
}
|
||||
|
||||
# Preflight purity is satisfied via explicit worktree_path on this tool's
|
||||
# recovery path; source remains frozen and is never cleaned.
|
||||
result = dirty_orphan_worktree_recovery.run_dirty_orphan_recovery(
|
||||
assessment=assessment,
|
||||
existing_lock=existing_lock or {},
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
source_worktree_path=src,
|
||||
recovery_worktree_path=recovery_worktree_path,
|
||||
remote=remote if remote else "prgs",
|
||||
org=o,
|
||||
repo=r,
|
||||
identity=identity,
|
||||
profile=profile,
|
||||
expected_local_head=expected_local_head,
|
||||
expected_remote_head=expected_remote_head,
|
||||
expected_dirty_fingerprints=expected_dirty_fingerprints or {},
|
||||
dirty_contents=dirty_contents,
|
||||
local_head_contents=local_contents,
|
||||
remote_head_contents=remote_contents,
|
||||
canonical_repo_root=canonical_root,
|
||||
bind_lock=True,
|
||||
session_pid=os.getpid(),
|
||||
)
|
||||
# Surface preflight recognition for recovered provenance.
|
||||
if result.get("success") and result.get("lock_record"):
|
||||
result["preflight_provenance"] = (
|
||||
dirty_orphan_worktree_recovery.preflight_recognizes_recovered_provenance(
|
||||
result["lock_record"]
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_assess_work_issue_duplicate(
|
||||
issue_number: int,
|
||||
|
||||
Reference in New Issue
Block a user