From a3f8f67c9323ec7e78b5c7cc8ce8b051ab061e23 Mon Sep 17 00:00:00 2001 From: jcwalker3 Date: Thu, 23 Jul 2026 22:54:15 -0500 Subject: [PATCH] feat(author): add dirty-preserving same-claimant author-session rebind (Closes #864) Introduce an explicit, fail-closed recovery operation that rebinds a live author session to an already-registered dirty issue worktree when the durable lock still belongs to the same identity/profile and the recorded owner PID is provably dead. Ordinary locking remains clean-worktree-only; this path updates only stale lock/session provenance, preserves every dirty byte under fingerprint pins, and grants create-PR-sanctioned provenance without remote sync or recovery worktrees. Also stop treating dead-owner session pointers as live mutation workspace bindings so a stale dead-owner pointer cannot poison unrelated author work. --- dirty_same_claimant_session_rebind.py | 1232 +++++++++++++++++ gitea_mcp_server.py | 279 +++- issue_lock_provenance.py | 5 + task_capability_map.py | 11 + ...test_dirty_same_claimant_session_rebind.py | 845 +++++++++++ 5 files changed, 2371 insertions(+), 1 deletion(-) create mode 100644 dirty_same_claimant_session_rebind.py create mode 100644 tests/test_dirty_same_claimant_session_rebind.py diff --git a/dirty_same_claimant_session_rebind.py b/dirty_same_claimant_session_rebind.py new file mode 100644 index 0000000..1dda6b8 --- /dev/null +++ b/dirty_same_claimant_session_rebind.py @@ -0,0 +1,1232 @@ +"""Dirty-preserving same-claimant author-session rebind (#864). + +A registered issue worktree can be dirty while its durable lock owner PID is +provably dead. Ordinary ``gitea_lock_issue`` refuses dirty trees, and dead-session +recovery (#753) also requires cleanliness. This module is the *only* sanctioned +path that rebinds session/lock provenance onto the *same* worktree without +touching tracked or untracked content. + +This is SEPARATE from #860 dirty-orphan recovery (PID-less + remote sync). +This operation: + +* acts only on an already-registered dirty worktree +* updates only stale lock/session provenance (PID, session pointer, generation, + heartbeat) +* preserves every tracked/untracked byte +* does NOT sync remote, create recovery worktrees, clean, reset, or change heads +""" + +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import tempfile +from datetime import datetime, timezone +from typing import Any, Mapping, Sequence + +from author_mutation_worktree import is_path_under_branches +from issue_lock_provenance import ( + SOURCE_DIRTY_SAME_CLAIMANT_REBIND, + build_sanctioned_lock_provenance, +) +from issue_lock_store import ( + AUTHOR_ISSUE_WORK_LEASE, + bind_session_lock, + is_process_alive, + lock_file_path, + lock_generation, + read_lock_file, +) +from reviewer_worktree import parse_dirty_tracked_files + +# Outcomes +REBIND_SANCTIONED = "REBIND_SANCTIONED" +REFUSED = "REFUSED" +NO_CANDIDATE = "NO_CANDIDATE" + +# Provenance / tool identity +SOURCE_TOOL = SOURCE_DIRTY_SAME_CLAIMANT_REBIND +SOURCE = SOURCE_DIRTY_SAME_CLAIMANT_REBIND + +# Journal phases (crash-safe apply) +JOURNAL_PHASE_ASSESSED = "assessed" +JOURNAL_PHASE_PRE_BIND = "pre_bind" +JOURNAL_PHASE_BOUND = "bound" +JOURNAL_PHASE_COMPLETE = "complete" +JOURNAL_PHASE_ALREADY_REBOUND = "already_rebound" + +REQUIRED_LOCK_FIELDS = ( + "issue_number", + "branch_name", + "worktree_path", + "remote", + "org", + "repo", +) + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +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 _lock_claimant(lock: Mapping[str, Any]) -> dict[str, Any]: + claimant = lock.get("claimant") + if not isinstance(claimant, Mapping): + lease = lock.get("work_lease") + claimant = lease.get("claimant") if isinstance(lease, Mapping) else None + return dict(claimant) if isinstance(claimant, Mapping) else {} + + +def _recorded_pid(lock: Mapping[str, Any]) -> Any: + pid = lock.get("session_pid") + if pid is None: + pid = lock.get("pid") + return pid + + +def content_fingerprint(path: str) -> str: + """Return sha256 hex digest of file bytes at *path*. + + Missing or unreadable files raise ``OSError`` / ``FileNotFoundError`` so + callers fail closed rather than inventing an empty hash. + """ + digest = hashlib.sha256() + with open(path, "rb") as handle: + while True: + chunk = handle.read(1024 * 1024) + if not chunk: + break + digest.update(chunk) + return digest.hexdigest() + + +def parse_dirty_paths(porcelain: str) -> list[str]: + """Tracked + untracked paths from ``git status --porcelain -uall`` output.""" + paths: list[str] = [] + seen: set[str] = set() + for line in (porcelain or "").splitlines(): + if not line or len(line) < 4: + continue + if line.startswith("??"): + path = line[3:].strip() + else: + path = line[3:].strip() + if " -> " in path: + path = path.split(" -> ", 1)[1].strip() + if not path or path in seen: + continue + seen.add(path) + paths.append(path) + return paths + + +def collect_dirty_inventory(worktree_path: str) -> dict[str, Any]: + """Observe dirty tracked + untracked paths and content fingerprints. + + Uses ``git status --porcelain -uall`` so every untracked file is listed + individually (not collapsed into a directory). + """ + path = (worktree_path or "").strip() + if not path: + return { + "worktree_path": path, + "porcelain_status": "", + "dirty_paths": [], + "fingerprints": {}, + "ok": False, + "reasons": ["worktree path is empty"], + } + + status_res = subprocess.run( + ["git", "-C", path, "status", "--porcelain", "-uall"], + capture_output=True, + text=True, + check=False, + ) + if status_res.returncode != 0: + err = (status_res.stderr or status_res.stdout or "").strip() + return { + "worktree_path": path, + "porcelain_status": "", + "dirty_paths": [], + "fingerprints": {}, + "ok": False, + "reasons": [f"git status failed in '{path}': {err or 'unknown error'}"], + } + + porcelain = status_res.stdout or "" + dirty_paths = parse_dirty_paths(porcelain) + fingerprints: dict[str, str] = {} + reasons: list[str] = [] + for rel in dirty_paths: + abs_path = os.path.join(path, rel) + if os.path.isdir(abs_path) and not os.path.islink(abs_path): + # Directories appear only if git reported them; fingerprinting a + # directory is not defined — fail closed. + reasons.append(f"dirty path '{rel}' is a directory; cannot fingerprint") + continue + try: + fingerprints[rel] = content_fingerprint(abs_path) + except OSError as exc: + reasons.append(f"could not fingerprint '{rel}': {exc}") + + return { + "worktree_path": os.path.realpath(path), + "porcelain_status": porcelain, + "dirty_paths": dirty_paths, + "fingerprints": fingerprints, + "ok": not reasons, + "reasons": reasons, + "tracked_dirty": parse_dirty_tracked_files(porcelain), + } + + +def journal_path(lock_dir: str, issue_number: int) -> str: + root = (lock_dir or "").strip() + return os.path.join(root, f".rebind-journal-{int(issue_number)}.json") + + +def _atomic_write_json(path: str, data: dict[str, Any]) -> None: + parent = os.path.dirname(path) or "." + os.makedirs(parent, mode=0o700, exist_ok=True) + payload = json.dumps(data, indent=2, sort_keys=True) + "\n" + fd, temp_path = tempfile.mkstemp(prefix=".rebind-j-", suffix=".json", dir=parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, path) + finally: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError: + pass + + +def _read_json(path: str) -> dict[str, Any] | None: + if not path or not os.path.exists(path): + return None + try: + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None + + +def _malformed_lock_reasons(lock: Mapping[str, Any]) -> list[str]: + missing: list[str] = [] + for field in REQUIRED_LOCK_FIELDS: + if not _text(lock.get(field)): + missing.append(field) + pid = _recorded_pid(lock) + if pid is None or _text(pid) == "": + missing.append("session_pid/pid") + else: + try: + if int(pid) <= 0: + missing.append("session_pid/pid") + except (TypeError, ValueError): + missing.append("session_pid/pid") + return missing + + +def _canonical_under_branches(worktree_path: str, repo_root: str | None) -> tuple[bool, list[str]]: + """Prove worktree is a realpath under ``/branches/`` with no symlink escape.""" + reasons: list[str] = [] + path = (worktree_path or "").strip() + if not path: + return False, ["worktree path is empty"] + try: + real = os.path.realpath(path) + except OSError as exc: + return False, [f"worktree path could not be realpath-resolved: {exc}"] + if not os.path.isdir(real): + reasons.append(f"worktree path '{path}' is not an existing directory") + + root = (repo_root or "").strip() + if root: + try: + root_real = os.path.realpath(root) + except OSError as exc: + return False, [f"repo root could not be realpath-resolved: {exc}"] + if not is_path_under_branches(real, root_real): + reasons.append( + f"worktree '{real}' is not under branches/ of repo root '{root_real}' " + "(unregistered/noncanonical worktree; fail closed)" + ) + # Symlink escape: the declared path must not resolve outside branches/. + declared_abs = os.path.abspath(path) + if os.path.islink(path) or declared_abs != real: + if not is_path_under_branches(real, root_real): + reasons.append( + f"worktree path '{path}' escapes branches/ via symlink/realpath " + f"(resolves to '{real}')" + ) + else: + # Without an explicit repo root, still require a /branches/ segment. + if not is_path_under_branches(real, None): + reasons.append( + f"worktree '{real}' is not under a branches/ directory " + "(unregistered/noncanonical worktree; fail closed)" + ) + return not reasons, reasons + + +def assess_dirty_same_claimant_session_rebind( + *, + remote: str, + org: str, + repo: str, + issue_number: int, + branch_name: str, + worktree_path: str, + claimant_identity: str | None, + claimant_profile: str | None, + old_pid: int | None, + expected_local_head: str | None, + expected_remote_head: str | None, + expected_dirty_paths: Sequence[str] | None, + expected_fingerprints: Mapping[str, str] | None, + existing_lock: Mapping[str, Any] | None, + current_identity: str | None, + current_profile: str | None, + role_kind: str | None, + current_pid: int | None, + current_branch: str | None, + local_head: str | None, + remote_head: str | None, + porcelain_status: str | None = None, + dirty_inventory: Mapping[str, Any] | None = None, + competing_live_locks: Sequence[Mapping[str, Any]] | None = None, + competing_sessions: Sequence[Mapping[str, Any]] | None = None, + workflow_lease_active: bool = False, + authorize_reconciler_execute: bool = False, + permission_allowed: bool = False, + repo_root: str | None = None, +) -> dict[str, Any]: + """Pure assessment: may this dirty same-claimant lock be session-rebound? + + Every pin must agree. ``permission_allowed=True`` alone is never ownership + proof. Fail closed on live old PID, foreign identity/profile, pin mismatch, + unregistered/noncanonical worktree, head movement, dirty path/fingerprint + disagreement, competing ownership, malformed lock, empty PID, wrong role. + """ + reasons: list[str] = [] + evidence: dict[str, Any] = { + "issue_number": issue_number, + "branch_name": branch_name, + "worktree_path": worktree_path, + "remote": remote, + "org": org, + "repo": repo, + "old_pid": old_pid, + "current_pid": current_pid if current_pid is not None else os.getpid(), + "role_kind": _text(role_kind).lower() or None, + "permission_allowed": bool(permission_allowed), + } + + if not existing_lock: + return _assessment_result( + NO_CANDIDATE, + False, + ["no existing durable lock for this issue; not a rebind candidate"], + evidence, + ) + + lock = dict(existing_lock) + if lock.get("issue_number") != issue_number: + return _assessment_result( + NO_CANDIDATE, + False, + [ + f"existing lock targets issue #{lock.get('issue_number')}, " + f"not #{issue_number}; not a rebind candidate" + ], + evidence, + ) + + missing = _malformed_lock_reasons(lock) + if missing: + return _assessment_result( + REFUSED, + False, + [ + "durable lock record is incomplete and cannot prove ownership " + f"(missing/unusable: {', '.join(missing)})" + ], + evidence, + ) + + recorded_pid = _recorded_pid(lock) + evidence["recorded_pid"] = recorded_pid + evidence["lock_generation"] = lock_generation(lock) + + # ── Role gate ─────────────────────────────────────────────────────────── + role = _text(role_kind).lower() + if role in {"reviewer", "merger"}: + reasons.append( + f"role '{role}' cannot rebind dirty same-claimant author sessions " + "(fail closed)" + ) + elif role == "reconciler": + if not authorize_reconciler_execute: + reasons.append( + "reconciler role requires authorize_reconciler_execute=True " + "to execute dirty same-claimant rebind (fail closed)" + ) + elif role == "author": + pass + elif role: + reasons.append(f"role '{role}' is not authorized for dirty same-claimant rebind") + else: + reasons.append("role_kind is unknown; dirty same-claimant rebind refused") + + # permission_allowed is explicitly NOT ownership proof + evidence["note_permission_not_ownership"] = ( + "permission_allowed is not treated as ownership proof" + ) + + # ── Repository / issue / branch / worktree pins ───────────────────────── + for field, expected in (("remote", remote), ("org", org), ("repo", repo)): + actual = _text(lock.get(field)) + if actual != _text(expected): + reasons.append( + f"lock {field} '{actual}' does not match requested '{_text(expected)}'" + ) + + 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)}'" + ) + + checked_out = _text(current_branch) + if not checked_out: + reasons.append( + "worktree is not on a named branch (detached HEAD); locked-branch " + "occupancy could not be proven" + ) + elif checked_out != locked_branch: + reasons.append( + f"worktree is on branch '{checked_out}', not the locked branch " + f"'{locked_branch}'" + ) + + locked_worktree = _text(lock.get("worktree_path")) + if not _same_realpath(locked_worktree, worktree_path): + reasons.append( + f"lock worktree '{locked_worktree}' does not match declared " + f"'{_text(worktree_path)}'" + ) + evidence["locked_worktree_path"] = locked_worktree + + under_ok, under_reasons = _canonical_under_branches(worktree_path, repo_root) + if not under_ok: + reasons.extend(under_reasons) + + # ── old_pid pin + liveness ────────────────────────────────────────────── + if old_pid is None or _text(old_pid) == "": + reasons.append("old_pid pin is empty; rebind refused (fail closed)") + else: + try: + old_pid_i = int(old_pid) + except (TypeError, ValueError): + reasons.append(f"old_pid '{old_pid}' is not a valid PID") + old_pid_i = None + if old_pid_i is not None: + if old_pid_i <= 0: + reasons.append("old_pid must be a positive integer (fail closed)") + try: + recorded_i = int(recorded_pid) + except (TypeError, ValueError): + recorded_i = None + if recorded_i is None or recorded_i != old_pid_i: + reasons.append( + f"old_pid {old_pid_i} does not match lock session_pid/pid " + f"{recorded_pid}" + ) + if is_process_alive(old_pid_i): + reasons.append( + f"old_pid {old_pid_i} is still alive; dirty same-claimant " + "rebind requires a provably dead owner (fail closed)" + ) + evidence["old_pid_alive"] = is_process_alive(old_pid_i) + if current_pid is not None: + try: + if int(current_pid) == old_pid_i: + reasons.append( + "old_pid is the current session PID; nothing to rebind" + ) + except (TypeError, ValueError): + pass + + # ── Claimant identity / profile ───────────────────────────────────────── + lock_claimant = _lock_claimant(lock) + locked_identity = _text(lock_claimant.get("username")) + locked_profile = _text(lock_claimant.get("profile")) + pin_identity = _text(claimant_identity) + pin_profile = _text(claimant_profile) + active_identity = _text(current_identity) + active_profile = _text(current_profile) + evidence["locked_identity"] = locked_identity or None + evidence["locked_profile"] = locked_profile or None + + if not locked_identity or not locked_profile: + reasons.append( + "durable lock does not record a claimant identity/profile; " + "ownership could not be proven" + ) + if not pin_identity or not pin_profile: + reasons.append( + "claimant_identity/claimant_profile pins are required (fail closed)" + ) + if locked_identity and pin_identity and locked_identity != pin_identity: + reasons.append( + f"claimant_identity pin '{pin_identity}' does not match lock " + f"claimant '{locked_identity}'" + ) + if locked_profile and pin_profile and locked_profile != pin_profile: + reasons.append( + f"claimant_profile pin '{pin_profile}' does not match lock profile " + f"'{locked_profile}'" + ) + + # Author path: active session must be the same claimant. Reconciler execute + # may rebind for the recorded claimant when explicitly authorized. + if role == "author": + if not active_identity or not active_profile: + reasons.append( + "active session identity/profile is unknown; author ownership " + "could not be proven" + ) + if locked_identity and active_identity and locked_identity != active_identity: + reasons.append( + f"lock claimant '{locked_identity}' does not match active " + f"identity '{active_identity}' (foreign claimant refused)" + ) + if locked_profile and active_profile and locked_profile != active_profile: + reasons.append( + f"lock profile '{locked_profile}' does not match active profile " + f"'{active_profile}' (profile mismatch refused)" + ) + if pin_identity and active_identity and pin_identity != active_identity: + reasons.append( + f"claimant_identity pin '{pin_identity}' does not match active " + f"identity '{active_identity}'" + ) + if pin_profile and active_profile and pin_profile != active_profile: + reasons.append( + f"claimant_profile pin '{pin_profile}' does not match active " + f"profile '{active_profile}'" + ) + + # ── Heads (must match pins and each other for this rebind class) ──────── + obs_local = _text(local_head) + obs_remote = _text(remote_head) + pin_local = _text(expected_local_head) + pin_remote = _text(expected_remote_head) + evidence["local_head"] = obs_local or None + evidence["remote_head"] = obs_remote or None + evidence["expected_local_head"] = pin_local or None + evidence["expected_remote_head"] = pin_remote or None + + if not pin_local or not pin_remote: + reasons.append( + "expected_local_head and expected_remote_head pins are required " + "(fail closed)" + ) + if not obs_local: + reasons.append("local head SHA could not be determined") + if not obs_remote: + reasons.append("remote head SHA could not be determined") + if pin_local and obs_local and pin_local != obs_local: + reasons.append( + f"local head moved or mismatched pin: observed {obs_local}, " + f"expected {pin_local}" + ) + if pin_remote and obs_remote and pin_remote != obs_remote: + reasons.append( + f"remote head moved or mismatched pin: observed {obs_remote}, " + f"expected {pin_remote}" + ) + if obs_local and obs_remote and obs_local != obs_remote: + # Dirty rebind does not allow unpublished head movement; heads must agree. + reasons.append( + f"local head {obs_local} does not match remote head {obs_remote}; " + "dirty same-claimant rebind requires matching heads (fail closed)" + ) + + # ── Dirty inventory + fingerprint pins ────────────────────────────────── + inv: dict[str, Any] + if isinstance(dirty_inventory, Mapping) and dirty_inventory.get("dirty_paths") is not None: + inv = dict(dirty_inventory) + if not inv.get("fingerprints") and porcelain_status is not None: + # Allow fingerprints-only refresh via recompute if needed. + pass + elif porcelain_status is not None: + # Porcelain alone proves path set, not bytes. Fingerprints must come from + # dirty_inventory (or apply()'s collect_dirty_inventory) — never from the + # caller's expected_fingerprints pin (that would make the pin tautological). + dirty_paths_obs = parse_dirty_paths(porcelain_status) + inv = { + "porcelain_status": porcelain_status, + "dirty_paths": dirty_paths_obs, + "fingerprints": {}, + "ok": True, + "reasons": [], + } + else: + reasons.append( + "neither dirty_inventory nor porcelain_status was provided; " + "dirty state could not be proven" + ) + inv = {"dirty_paths": [], "fingerprints": {}, "ok": False} + + if inv.get("ok") is False and inv.get("reasons"): + reasons.extend(list(inv.get("reasons") or [])) + + observed_paths = sorted({_text(p) for p in (inv.get("dirty_paths") or []) if _text(p)}) + pin_paths = sorted({_text(p) for p in (expected_dirty_paths or []) if _text(p)}) + evidence["observed_dirty_paths"] = observed_paths + evidence["expected_dirty_paths"] = pin_paths + + if not pin_paths: + reasons.append( + "expected_dirty_paths pin is empty; dirty same-claimant rebind " + "requires a non-empty dirty inventory pin (fail closed)" + ) + if set(observed_paths) != set(pin_paths): + extra = sorted(set(observed_paths) - set(pin_paths)) + missing_p = sorted(set(pin_paths) - set(observed_paths)) + if extra: + reasons.append( + f"dirty path set disagreement: unexpected paths {extra}" + ) + if missing_p: + reasons.append( + f"dirty path set disagreement: missing expected paths {missing_p}" + ) + + obs_fps = { + _text(k): _text(v) + for k, v in dict(inv.get("fingerprints") or {}).items() + if _text(k) + } + pin_fps = { + _text(k): _text(v) + for k, v in dict(expected_fingerprints or {}).items() + if _text(k) + } + evidence["observed_fingerprints"] = obs_fps + evidence["expected_fingerprints"] = pin_fps + + if not pin_fps: + reasons.append( + "expected_fingerprints pin is empty; byte-level pins are required " + "(fail closed)" + ) + else: + for rel, expected_hash in pin_fps.items(): + if rel not in set(pin_paths): + reasons.append( + f"expected_fingerprints contains '{rel}' which is not in " + "expected_dirty_paths" + ) + actual_hash = obs_fps.get(rel) + if not actual_hash: + reasons.append( + f"fingerprint missing for dirty path '{rel}'" + ) + elif actual_hash != expected_hash: + reasons.append( + f"fingerprint disagreement for '{rel}': observed " + f"{actual_hash}, expected {expected_hash}" + ) + for rel in obs_fps: + if rel in set(pin_paths) and rel not in pin_fps: + reasons.append( + f"expected_fingerprints missing pin for observed dirty path '{rel}'" + ) + + # ── Competing ownership ───────────────────────────────────────────────── + competing: list[dict[str, Any]] = [] + for entry in competing_live_locks or (): + if not isinstance(entry, Mapping): + continue + same_issue = entry.get("issue_number") == issue_number + same_branch = _text(entry.get("branch_name")) == locked_branch + if not (same_issue or same_branch): + continue + if ( + same_issue + and same_branch + and _same_realpath(_text(entry.get("worktree_path")), worktree_path) + ): + # The lock we are rebinding is not competition with itself, but a + # *live* competing owner on the same worktree is still a problem. + entry_pid = entry.get("pid") or entry.get("session_pid") + try: + entry_pid_i = int(entry_pid) if entry_pid is not None else None + except (TypeError, ValueError): + entry_pid_i = None + if entry_pid_i is not None and is_process_alive(entry_pid_i): + if old_pid is None or entry_pid_i != int(old_pid): + competing.append( + { + "issue_number": entry.get("issue_number"), + "branch_name": entry.get("branch_name"), + "worktree_path": entry.get("worktree_path"), + "pid": entry_pid_i, + } + ) + continue + competing.append( + { + "issue_number": entry.get("issue_number"), + "branch_name": entry.get("branch_name"), + "worktree_path": entry.get("worktree_path"), + "pid": entry.get("pid") or entry.get("session_pid"), + } + ) + if competing: + described = ", ".join( + f"issue #{c['issue_number']} branch '{c['branch_name']}' pid={c.get('pid')}" + for c in competing + ) + reasons.append(f"competing live lock exists ({described})") + evidence["competing_live_locks"] = competing + + competing_sess: list[dict[str, Any]] = [] + for entry in competing_sessions or (): + if not isinstance(entry, Mapping): + continue + sess_pid = entry.get("pid") or entry.get("session_pid") + try: + sess_pid_i = int(sess_pid) if sess_pid is not None else None + except (TypeError, ValueError): + sess_pid_i = None + if sess_pid_i is None: + continue + if current_pid is not None and sess_pid_i == int(current_pid): + continue + if old_pid is not None: + try: + if sess_pid_i == int(old_pid) and not is_process_alive(sess_pid_i): + continue + except (TypeError, ValueError): + pass + if is_process_alive(sess_pid_i) or entry.get("live") is True: + competing_sess.append( + { + "pid": sess_pid_i, + "lock_file_path": entry.get("lock_file_path"), + } + ) + if competing_sess: + reasons.append( + "competing live session pointer(s) claim this lock: " + + ", ".join(str(s["pid"]) for s in competing_sess) + ) + evidence["competing_sessions"] = competing_sess + + if workflow_lease_active: + reasons.append( + "workflow lease is active for this scope; dirty same-claimant " + "rebind refused (fail closed)" + ) + evidence["workflow_lease_active"] = bool(workflow_lease_active) + + if reasons: + return _assessment_result(REFUSED, False, reasons, evidence) + + proof = [ + f"registered dirty worktree for issue #{issue_number} on branch " + f"'{locked_branch}' matches claimant '{locked_identity}' / profile " + f"'{locked_profile}'; old_pid {recorded_pid} is dead; heads " + f"{obs_local} match; {len(pin_paths)} dirty paths fingerprint-pinned; " + "provenance-only rebind sanctioned" + ] + return _assessment_result(REBIND_SANCTIONED, True, proof, evidence) + + +def _assessment_result( + outcome: str, + sanctioned: bool, + reasons: list[str], + evidence: dict[str, Any], +) -> dict[str, Any]: + return { + "outcome": outcome, + "rebind_sanctioned": sanctioned, + "is_candidate": outcome != NO_CANDIDATE, + "reasons": reasons, + "evidence": evidence, + "expected_generation": evidence.get("lock_generation"), + } + + +def _already_rebound( + *, + existing_lock: Mapping[str, Any], + current_pid: int, + worktree_path: str, + expected_fingerprints: Mapping[str, str], + worktree_for_fps: str, +) -> tuple[bool, list[str]]: + """Return (True, notes) when lock is already rebound to this session.""" + notes: list[str] = [] + pid = _recorded_pid(existing_lock) + try: + pid_i = int(pid) if pid is not None else None + except (TypeError, ValueError): + return False, [] + if pid_i != int(current_pid): + return False, [] + if not _same_realpath(_text(existing_lock.get("worktree_path")), worktree_path): + return False, [] + # Fingerprints must still match pins (byte preservation). + for rel, expected in (expected_fingerprints or {}).items(): + abs_path = os.path.join(worktree_for_fps, rel) + try: + actual = content_fingerprint(abs_path) + except OSError as exc: + notes.append(f"could not re-fingerprint '{rel}' for already_rebound: {exc}") + return False, notes + if actual != _text(expected): + notes.append( + f"fingerprint drift on already-rebound check for '{rel}'" + ) + return False, notes + gen = lock_generation(existing_lock) + if gen < 1: + # A never-written generation is suspicious for a completed rebind, but + # a same-pid lock with matching fingerprints is still "ours". + notes.append("lock generation is 0; treating same-pid match as rebound") + return True, notes or ["lock already bound to current session PID"] + + +def apply_dirty_same_claimant_session_rebind( + *, + remote: str, + org: str, + repo: str, + issue_number: int, + branch_name: str, + worktree_path: str, + claimant_identity: str | None, + claimant_profile: str | None, + old_pid: int | None, + expected_local_head: str | None, + expected_remote_head: str | None, + expected_dirty_paths: Sequence[str] | None, + expected_fingerprints: Mapping[str, str] | None, + existing_lock: Mapping[str, Any] | None, + current_identity: str | None, + current_profile: str | None, + role_kind: str | None, + current_pid: int | None = None, + current_branch: str | None, + local_head: str | None, + remote_head: str | None, + porcelain_status: str | None = None, + dirty_inventory: Mapping[str, Any] | None = None, + competing_live_locks: Sequence[Mapping[str, Any]] | None = None, + competing_sessions: Sequence[Mapping[str, Any]] | None = None, + workflow_lease_active: bool = False, + authorize_reconciler_execute: bool = False, + permission_allowed: bool = False, + repo_root: str | None = None, + dry_run: bool = False, + lock_dir: str | None = None, +) -> dict[str, Any]: + """Assess and (unless dry_run) apply a dirty same-claimant session rebind.""" + pid_now = int(current_pid) if current_pid is not None else os.getpid() + wt = os.path.realpath((worktree_path or "").strip()) if worktree_path else "" + + # Prefer a live inventory when applying so fingerprints are re-observed. + inv = dict(dirty_inventory) if isinstance(dirty_inventory, Mapping) else None + if inv is None and wt: + inv = collect_dirty_inventory(wt) + + assessment = assess_dirty_same_claimant_session_rebind( + remote=remote, + org=org, + repo=repo, + issue_number=issue_number, + branch_name=branch_name, + worktree_path=worktree_path, + claimant_identity=claimant_identity, + claimant_profile=claimant_profile, + old_pid=old_pid, + expected_local_head=expected_local_head, + expected_remote_head=expected_remote_head, + expected_dirty_paths=expected_dirty_paths, + expected_fingerprints=expected_fingerprints, + existing_lock=existing_lock, + current_identity=current_identity, + current_profile=current_profile, + role_kind=role_kind, + current_pid=pid_now, + current_branch=current_branch, + local_head=local_head, + remote_head=remote_head, + porcelain_status=porcelain_status + if porcelain_status is not None + else (inv or {}).get("porcelain_status"), + dirty_inventory=inv, + competing_live_locks=competing_live_locks, + competing_sessions=competing_sessions, + workflow_lease_active=workflow_lease_active, + authorize_reconciler_execute=authorize_reconciler_execute, + permission_allowed=permission_allowed, + repo_root=repo_root, + ) + + base_result: dict[str, Any] = { + "success": False, + "dry_run": bool(dry_run), + "outcome": assessment["outcome"], + "rebind_sanctioned": assessment["rebind_sanctioned"], + "reasons": list(assessment.get("reasons") or []), + "evidence": assessment.get("evidence") or {}, + "old_pid": old_pid, + "new_pid": pid_now, + "already_rebound": False, + "dirty_paths": list(expected_dirty_paths or []), + "fingerprints": dict(expected_fingerprints or {}), + "local_head": local_head, + "remote_head": remote_head, + } + + # Retry-safe: if already rebound to this session, succeed even when assess + # refuses because old_pid no longer matches the (updated) lock. + if ( + isinstance(existing_lock, Mapping) + and expected_fingerprints + and wt + ): + done, notes = _already_rebound( + existing_lock=existing_lock, + current_pid=pid_now, + worktree_path=worktree_path, + expected_fingerprints=expected_fingerprints, + worktree_for_fps=wt, + ) + if done: + lock_path = _text(existing_lock.get("lock_file_path")) or lock_file_path( + remote=remote, + org=org, + repo=repo, + issue_number=issue_number, + lock_dir=lock_dir, + ) + session_ptr = os.path.join( + (lock_dir or os.path.dirname(lock_path) or "."), + f"session-{pid_now}.json", + ) + return { + **base_result, + "success": True, + "outcome": REBIND_SANCTIONED, + "rebind_sanctioned": True, + "already_rebound": True, + "reasons": notes, + "lock_path": lock_path, + "session_pointer": session_ptr, + "generation_before": lock_generation(existing_lock), + "generation_after": lock_generation(existing_lock), + "journal_phase": JOURNAL_PHASE_ALREADY_REBOUND, + } + + if not assessment["rebind_sanctioned"]: + return base_result + + if dry_run: + return { + **base_result, + "success": True, + "message": "dry_run: rebind sanctioned; no lock/session writes performed", + "generation_before": assessment.get("expected_generation"), + "generation_after": assessment.get("expected_generation"), + } + + lock = dict(existing_lock or {}) + gen_before = lock_generation(lock) + root = (lock_dir or "").strip() or None + jpath = journal_path( + root or os.path.dirname( + _text(lock.get("lock_file_path")) + or lock_file_path( + remote=remote, org=org, repo=repo, issue_number=issue_number + ) + ), + issue_number, + ) + + journal = { + "phase": JOURNAL_PHASE_ASSESSED, + "issue_number": issue_number, + "branch_name": branch_name, + "worktree_path": wt, + "old_pid": old_pid, + "new_pid": pid_now, + "expected_generation": gen_before, + "expected_fingerprints": dict(expected_fingerprints or {}), + "expected_dirty_paths": list(expected_dirty_paths or []), + "local_head": local_head, + "remote_head": remote_head, + "started_at": _utc_now_iso(), + "source": SOURCE, + } + _atomic_write_json(jpath, journal) + + # Immediate pre-bind fingerprint re-verification. + pre_fps: dict[str, str] = {} + for rel in expected_dirty_paths or []: + abs_path = os.path.join(wt, rel) + try: + pre_fps[rel] = content_fingerprint(abs_path) + except OSError as exc: + return { + **base_result, + "success": False, + "reasons": [f"pre-bind fingerprint failed for '{rel}': {exc}"], + "journal_path": jpath, + } + for rel, expected in (expected_fingerprints or {}).items(): + if pre_fps.get(rel) != _text(expected): + return { + **base_result, + "success": False, + "reasons": [ + f"pre-bind fingerprint drift for '{rel}': " + f"observed {pre_fps.get(rel)}, expected {expected}" + ], + "journal_path": jpath, + } + + journal["phase"] = JOURNAL_PHASE_PRE_BIND + journal["pre_bind_fingerprints"] = pre_fps + _atomic_write_json(jpath, journal) + + now = _utc_now_iso() + new_lock = dict(lock) + new_lock["session_pid"] = pid_now + new_lock["pid"] = pid_now + new_lock["last_heartbeat_at"] = now + new_lock["remote"] = remote + new_lock["org"] = org + new_lock["repo"] = repo + new_lock["issue_number"] = issue_number + new_lock["branch_name"] = branch_name + new_lock["worktree_path"] = _text(lock.get("worktree_path")) or wt + + # Preserve work_lease (including expires_at); refresh heartbeat only. + lease = new_lock.get("work_lease") + if isinstance(lease, dict): + lease = dict(lease) + lease["last_heartbeat_at"] = now + if not lease.get("operation_type"): + lease["operation_type"] = AUTHOR_ISSUE_WORK_LEASE + new_lock["work_lease"] = lease + + claimant = _lock_claimant(lock) + new_lock["lock_provenance"] = build_sanctioned_lock_provenance( + tool=SOURCE_TOOL, + source=SOURCE, + claimant=claimant or { + "username": claimant_identity, + "profile": claimant_profile, + }, + ) + new_lock["rebind_record"] = { + "source": SOURCE, + "old_pid": old_pid, + "new_pid": pid_now, + "rebound_at": now, + "local_head": local_head, + "remote_head": remote_head, + "dirty_path_count": len(list(expected_dirty_paths or [])), + "generation_before": gen_before, + "evidence": { + "fingerprints": dict(expected_fingerprints or {}), + "dirty_paths": list(expected_dirty_paths or []), + }, + } + + try: + lock_path = bind_session_lock( + new_lock, + lock_dir=root, + expected_generation=gen_before, + ) + except Exception as exc: + journal["phase"] = "bind_failed" + journal["error"] = str(exc) + _atomic_write_json(jpath, journal) + return { + **base_result, + "success": False, + "reasons": [f"bind_session_lock failed: {exc}"], + "journal_path": jpath, + "generation_before": gen_before, + } + + journal["phase"] = JOURNAL_PHASE_BOUND + journal["lock_path"] = lock_path + _atomic_write_json(jpath, journal) + + # Post-bind fingerprint verification — every byte unchanged. + post_fps: dict[str, str] = {} + for rel in expected_dirty_paths or []: + abs_path = os.path.join(wt, rel) + try: + post_fps[rel] = content_fingerprint(abs_path) + except OSError as exc: + return { + **base_result, + "success": False, + "reasons": [ + f"post-bind fingerprint failed for '{rel}': {exc}; " + "lock may be rebound but content verification failed" + ], + "lock_path": lock_path, + "journal_path": jpath, + "generation_before": gen_before, + } + for rel, expected in (expected_fingerprints or {}).items(): + if post_fps.get(rel) != _text(expected): + return { + **base_result, + "success": False, + "reasons": [ + f"post-bind fingerprint drift for '{rel}': " + f"observed {post_fps.get(rel)}, expected {expected}" + ], + "lock_path": lock_path, + "journal_path": jpath, + "generation_before": gen_before, + "fingerprints_after": post_fps, + } + + # Remove stale session pointer for old_pid when it points at this lock. + removed_old_pointer = False + if old_pid is not None and root: + old_ptr = os.path.join(root, f"session-{int(old_pid)}.json") + if os.path.exists(old_ptr): + ptr = _read_json(old_ptr) or {} + ptr_lock = _text(ptr.get("lock_file_path")) + if not ptr_lock or os.path.realpath(ptr_lock) == os.path.realpath(lock_path): + try: + os.remove(old_ptr) + removed_old_pointer = True + except OSError: + pass + + bound = read_lock_file(lock_path) or new_lock + gen_after = lock_generation(bound) + session_ptr = os.path.join( + root or os.path.dirname(lock_path), + f"session-{pid_now}.json", + ) + + journal["phase"] = JOURNAL_PHASE_COMPLETE + journal["completed_at"] = _utc_now_iso() + journal["generation_after"] = gen_after + journal["removed_old_session_pointer"] = removed_old_pointer + journal["post_bind_fingerprints"] = post_fps + _atomic_write_json(jpath, journal) + + return { + **base_result, + "success": True, + "message": ( + f"Rebound dirty same-claimant author session for issue #{issue_number} " + f"from dead pid {old_pid} to pid {pid_now}; dirty bytes preserved" + ), + "lock_path": lock_path, + "session_pointer": session_ptr, + "generation_before": gen_before, + "generation_after": gen_after, + "fingerprints": post_fps, + "fingerprints_before": pre_fps, + "removed_old_session_pointer": removed_old_pointer, + "journal_path": jpath, + "journal_phase": JOURNAL_PHASE_COMPLETE, + "rebind_record": bound.get("rebind_record") or new_lock.get("rebind_record"), + "lock_provenance": bound.get("lock_provenance"), + } + + +def build_issue_860_regression_fixture_spec() -> dict[str, Any]: + """Data-only fixture describing the #860 class scenario (no real mutation). + + Claimant jcwalker3 / prgs-author, dead PID, no live session pointer, seven + dirty paths with fingerprint pins, matching local/remote heads. + """ + dirty_paths = [ + "dirty_same_claimant_session_rebind.py", + "issue_lock_provenance.py", + "task_capability_map.py", + "gitea_mcp_server.py", + "tests/test_dirty_same_claimant_session_rebind.py", + "docs/runbook-dirty-rebind.md", + "scratch/notes-untracked.txt", + ] + # Stable placeholder digests — tests replace with real fingerprints when + # constructing on-disk fixtures. These exist so the spec is self-describing. + fingerprints = { + path: hashlib.sha256(f"issue-860-fixture:{path}".encode()).hexdigest() + for path in dirty_paths + } + head = "a" * 40 + dead = 424860 + return { + "issue_class": "issue-860-dirty-orphan-class-fixture", + "description": ( + "Registered dirty worktree, same claimant, dead owner PID, no live " + "session pointer, seven fingerprint-pinned dirty paths, matching heads. " + "Data only — does not mutate any real worktree." + ), + "remote": "prgs", + "org": "Scaled-Tech-Consulting", + "repo": "Gitea-Tools", + "issue_number": 860, + "branch_name": "fix/issue-860-dirty-orphan-recovery", + "worktree_path": "/scratch/branches/fix-issue-860-dirty-orphan-recovery", + "claimant_identity": "jcwalker3", + "claimant_profile": "prgs-author", + "old_pid": dead, + "old_pid_alive": False, + "live_session_pointer": None, + "expected_local_head": head, + "expected_remote_head": head, + "expected_dirty_paths": dirty_paths, + "expected_fingerprints": fingerprints, + "dirty_path_count": 7, + "role_kind": "author", + "notes": [ + "Distinct from #864 apply path: this fixture documents the #860 class " + "inputs (dead PID + dirty inventory) without remote sync or recovery " + "worktree creation.", + ], + } diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 04edea9..8e1661a 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -440,13 +440,32 @@ def _session_author_lock_worktree() -> str | None: Used to derive the author mutation workspace when no explicit ``worktree_path`` or env binding is provided. Never invents a path. + + #864: a session pointer whose owner PID is dead and is not this process + must not force workspace binding for other issues — rebind is required for + that issue, and a stale dead-owner pointer must not poison unrelated work. """ try: lock = issue_lock_store.read_session_issue_lock() or {} except Exception: return None path = (lock.get("worktree_path") or "").strip() - return path or None + if not path: + return None + pid = lock.get("session_pid") + if pid is None: + pid = lock.get("pid") + try: + pid_i = int(pid) if pid is not None else None + except (TypeError, ValueError): + pid_i = None + if ( + pid_i is not None + and pid_i != os.getpid() + and not issue_lock_store.is_process_alive(pid_i) + ): + return None + return path def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str: @@ -2031,6 +2050,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_same_claimant_session_rebind # noqa: E402 # #864 import stacked_pr_support # noqa: E402 import merge_approval_gate # noqa: E402 import review_quarantine # noqa: E402 # #695 contaminated formal-review quarantine @@ -4342,6 +4362,263 @@ def gitea_lock_issue( return result +@mcp.tool() +def gitea_rebind_dirty_same_claimant_author_session( + issue_number: int, + branch_name: str, + worktree_path: str, + old_pid: int, + expected_local_head: str, + expected_remote_head: str, + expected_dirty_paths: list[str], + expected_fingerprints: dict, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, + dry_run: bool = False, + authorize_reconciler_execute: bool = False, +) -> dict: + """Rebind a dirty registered issue worktree to this session (#864). + + Sanctioned only when every pin agrees: same claimant, dead old_pid matching + the durable lock, matching local/remote heads, exact dirty path set, and + per-path sha256 fingerprints. Preserves every tracked/untracked byte. + Does not sync remote, create recovery worktrees, clean, reset, or move heads. + + Role gate: + * author — must match the lock claimant identity/profile + * reconciler — execute only when ``authorize_reconciler_execute=True`` + * reviewer/merger — always refuse + + ``gitea.issue.comment`` (author map entry) is required for mutation; dry_run + still assesses fully but writes nothing. Permission alone is never ownership + proof — every pin is re-checked server-side. + + Args: + issue_number: Tracking issue number on the durable lock. + branch_name: Exact locked branch name. + worktree_path: Registered dirty worktree path (must be under branches/). + old_pid: Dead owner PID recorded on the lock (must match session_pid/pid). + expected_local_head: Full local HEAD sha the caller observed. + expected_remote_head: Full remote-tracking HEAD sha the caller observed. + expected_dirty_paths: Exact set of dirty relative paths (tracked+untracked). + expected_fingerprints: Map of relative path -> sha256 hex of file bytes. + remote: Known instance — 'dadeschools' or 'prgs'. + host/org/repo: Optional target overrides (validated against binding). + dry_run: When true, assess only (no lock/session writes). + authorize_reconciler_execute: Reconciler-only execute gate. + """ + role = _profile_role_kind(get_profile()) + role_norm = (role or "").strip().lower() + + # Permission: authors need comment; dry_run assess is reachable under read + # for diagnosis, but execute always needs comment. Reconciler execute also + # needs comment when authorized. + if dry_run: + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "dry_run": True, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + else: + blocked = _profile_permission_block( + task_capability_map.required_permission( + "rebind_dirty_same_claimant_author_session" + ), + issue_number=issue_number, + remote=remote, + host=host, + org=org, + repo=repo, + org_explicit=org is not None, + repo_explicit=repo is not None, + ) + if blocked: + return blocked + + if role_norm in {"reviewer", "merger"}: + return { + "success": False, + "dry_run": bool(dry_run), + "outcome": dirty_same_claimant_session_rebind.REFUSED, + "reasons": [ + f"role '{role_norm}' cannot rebind dirty same-claimant author " + "sessions (fail closed)" + ], + } + if role_norm == "reconciler" and not authorize_reconciler_execute and not dry_run: + return { + "success": False, + "dry_run": False, + "outcome": dirty_same_claimant_session_rebind.REFUSED, + "reasons": [ + "reconciler role requires authorize_reconciler_execute=True " + "to execute dirty same-claimant rebind (fail closed)" + ], + } + + h, o, r = _resolve(remote, host, org, repo) + try: + identity = _authenticated_username(h) + except Exception: + identity = None + profile = get_profile() + profile_name = profile.get("profile_name") + + existing = _load_existing_issue_lock( + remote=remote, org=o, repo=r, issue_number=issue_number + ) + resolved_wt = os.path.realpath(os.path.abspath((worktree_path or "").strip())) + inv = dirty_same_claimant_session_rebind.collect_dirty_inventory(resolved_wt) + + branch_res = subprocess.run( + ["git", "-C", resolved_wt, "branch", "--show-current"], + capture_output=True, + text=True, + check=False, + ) + current_branch = (branch_res.stdout or "").strip() or None + head_res = subprocess.run( + ["git", "-C", resolved_wt, "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=False, + ) + local_head = (head_res.stdout or "").strip() if head_res.returncode == 0 else None + + # Observe remote-tracking head without network when possible. + remote_head = None + for ref in ( + f"refs/remotes/origin/{branch_name}", + f"origin/{branch_name}", + f"refs/remotes/{remote}/{branch_name}", + f"{remote}/{branch_name}", + ): + rh = subprocess.run( + ["git", "-C", resolved_wt, "rev-parse", "--verify", "--quiet", ref], + capture_output=True, + text=True, + check=False, + ) + if rh.returncode == 0 and (rh.stdout or "").strip(): + remote_head = (rh.stdout or "").strip() + break + if remote_head is None: + # Fall back to caller's pin only for observation absence — assessment + # still requires pin==observed, so missing observation fails closed. + remote_head = None + + # Competing live locks (other issues / other worktrees). + competing_live = [] + for entry in issue_lock_store.list_live_locks(): + competing_live.append(entry) + + # Session pointers that claim this issue lock. + competing_sessions = [] + lock_dir = issue_lock_store.default_lock_dir() + lock_path = issue_lock_store.lock_file_path( + remote=remote, org=o, repo=r, issue_number=issue_number, lock_dir=lock_dir + ) + try: + for name in os.listdir(lock_dir): + if not name.startswith("session-") or not name.endswith(".json"): + continue + ptr = issue_lock_store.read_lock_file(os.path.join(lock_dir, name)) + if not ptr: + continue + ptr_lock = str(ptr.get("lock_file_path") or "").strip() + if not ptr_lock: + continue + try: + same = os.path.realpath(ptr_lock) == os.path.realpath(lock_path) + except OSError: + same = ptr_lock == lock_path + if not same: + continue + try: + sess_pid = int(str(name)[len("session-") : -len(".json")]) + except ValueError: + sess_pid = ptr.get("pid") + competing_sessions.append( + { + "pid": sess_pid, + "lock_file_path": ptr_lock, + "live": issue_lock_store.is_process_alive(sess_pid), + } + ) + except OSError: + pass + + # Best-effort workflow-lease scan: any live lock file whose work_lease is a + # non-author workflow lease on this issue/branch counts as active. + workflow_lease_active = False + for path in issue_lock_store.iter_lock_files(lock_dir): + rec = issue_lock_store.read_lock_file(path) + if not rec: + continue + lease = rec.get("work_lease") if isinstance(rec.get("work_lease"), dict) else {} + op = str(lease.get("operation_type") or "") + if op and op != issue_lock_store.AUTHOR_ISSUE_WORK_LEASE: + if rec.get("issue_number") == issue_number or str( + rec.get("branch_name") or "" + ) == branch_name: + if issue_lock_store.is_lease_live(rec): + workflow_lease_active = True + break + + repo_root = _canonical_local_git_root() + # permission_allowed reflects profile gate only — never ownership proof. + permission_allowed = True + + result = dirty_same_claimant_session_rebind.apply_dirty_same_claimant_session_rebind( + remote=remote, + org=o, + repo=r, + issue_number=issue_number, + branch_name=branch_name, + worktree_path=resolved_wt, + claimant_identity=identity, + claimant_profile=profile_name, + old_pid=old_pid, + expected_local_head=expected_local_head, + expected_remote_head=expected_remote_head, + expected_dirty_paths=list(expected_dirty_paths or []), + expected_fingerprints=dict(expected_fingerprints or {}), + existing_lock=existing, + current_identity=identity, + current_profile=profile_name, + role_kind=role_norm or role, + current_pid=os.getpid(), + current_branch=current_branch, + local_head=local_head, + remote_head=remote_head, + dirty_inventory=inv, + competing_live_locks=competing_live, + competing_sessions=competing_sessions, + workflow_lease_active=workflow_lease_active, + authorize_reconciler_execute=bool(authorize_reconciler_execute), + permission_allowed=permission_allowed, + repo_root=repo_root, + dry_run=bool(dry_run), + lock_dir=lock_dir, + ) + result["observed"] = { + "local_head": local_head, + "remote_head": remote_head, + "current_branch": current_branch, + "dirty_paths": inv.get("dirty_paths"), + "fingerprints": inv.get("fingerprints"), + "identity": identity, + "profile": profile_name, + "role_kind": role_norm, + } + return result + + @mcp.tool() def gitea_assess_work_issue_duplicate( issue_number: int, diff --git a/issue_lock_provenance.py b/issue_lock_provenance.py index 87ee38f..544e017 100644 --- a/issue_lock_provenance.py +++ b/issue_lock_provenance.py @@ -16,11 +16,16 @@ ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock SOURCE_LOCK_ISSUE = "gitea_lock_issue" SOURCE_LOCK_ADOPTION = "gitea_lock_issue_adoption" SOURCE_OPERATOR_OVERRIDE = "operator_override" +# #864: dirty-preserving same-claimant author-session rebind (dead owner PID). +SOURCE_DIRTY_SAME_CLAIMANT_REBIND = ( + "gitea_rebind_dirty_same_claimant_author_session" +) SANCTIONED_LOCK_SOURCES = frozenset({ SOURCE_LOCK_ISSUE, SOURCE_LOCK_ADOPTION, SOURCE_OPERATOR_OVERRIDE, + SOURCE_DIRTY_SAME_CLAIMANT_REBIND, }) _OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE" diff --git a/task_capability_map.py b/task_capability_map.py index 0b8ac0b..7d7b76b 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -32,6 +32,17 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { "permission": "gitea.issue.comment", "role": "author", }, + # #864: dirty-preserving same-claimant author-session rebind (dead owner PID). + # Author MCP tool path. Reconciler execute is gated inside the tool via + # authorize_reconciler_execute + role_kind checks (not this map entry). + "rebind_dirty_same_claimant_author_session": { + "permission": "gitea.issue.comment", + "role": "author", + }, + "gitea_rebind_dirty_same_claimant_author_session": { + "permission": "gitea.issue.comment", + "role": "author", + }, "set_issue_labels": { "permission": "gitea.issue.comment", "role": "author", diff --git a/tests/test_dirty_same_claimant_session_rebind.py b/tests/test_dirty_same_claimant_session_rebind.py new file mode 100644 index 0000000..7208e5f --- /dev/null +++ b/tests/test_dirty_same_claimant_session_rebind.py @@ -0,0 +1,845 @@ +"""Integration tests for dirty same-claimant author-session rebind (#864). + +Uses real temp git repos/worktrees and a temp GITEA_ISSUE_LOCK_DIR. Does not +mutate any real #860/#864 worktree on disk. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import dirty_same_claimant_session_rebind as rebind # noqa: E402 +import issue_lock_provenance # noqa: E402 +import issue_lock_store as ils # noqa: E402 +import issue_lock_worktree # noqa: E402 + +ISSUE = 864 +BRANCH = f"fix/issue-{ISSUE}-dirty-same-claimant-session-rebind" +REMOTE = "prgs" +ORG = "Scaled-Tech-Consulting" +REPO = "Gitea-Tools" +IDENTITY = "jcwalker3" +PROFILE = "prgs-author" + + +def _git(cwd: str, *args: str, check: bool = True) -> subprocess.CompletedProcess: + return subprocess.run( + ["git", "-C", cwd, *args], + capture_output=True, + text=True, + check=check, + ) + + +def dead_pid() -> int: + proc = subprocess.Popen([sys.executable, "-c", "pass"]) + proc.wait() + return proc.pid + + +def future_ts(hours: int = 4) -> str: + return ( + (datetime.now(timezone.utc) + timedelta(hours=hours)) + .isoformat() + .replace("+00:00", "Z") + ) + + +@pytest.fixture +def lock_dir(tmp_path, monkeypatch): + d = tmp_path / "issue-locks" + d.mkdir() + monkeypatch.setenv("GITEA_ISSUE_LOCK_DIR", str(d)) + return str(d) + + +@pytest.fixture +def dirty_repo(tmp_path): + """Canonical repo root with branches/ worktree and dirty content.""" + root = tmp_path / "repo" + root.mkdir() + main = root / "main" + main.mkdir() + subprocess.run(["git", "init", "-q", str(main)], check=True, capture_output=True) + _git(str(main), "config", "user.email", "t@t") + _git(str(main), "config", "user.name", "t") + (main / "README.md").write_text("base\n", encoding="utf-8") + _git(str(main), "add", "README.md") + _git(str(main), "commit", "-q", "-m", "base") + _git(str(main), "branch", "-M", "master") + + # Bare remote + origin tracking so remote head is observable offline. + bare = tmp_path / "remote.git" + subprocess.run( + ["git", "init", "--bare", "-q", str(bare)], check=True, capture_output=True + ) + _git(str(main), "remote", "add", "origin", str(bare)) + _git(str(main), "push", "-q", "origin", "master:master") + + branches = root / "branches" + branches.mkdir() + wt_name = f"fix-issue-{ISSUE}-dirty-same-claimant-session-rebind" + wt = branches / wt_name + _git(str(main), "worktree", "add", "-q", "-b", BRANCH, str(wt)) + _git(str(wt), "push", "-q", "-u", "origin", BRANCH) + + # Seed committed files we will dirty. + tracked = [ + "dirty_same_claimant_session_rebind.py", + "issue_lock_provenance.py", + "task_capability_map.py", + ] + for rel in tracked: + p = wt / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(f"seed {rel}\n", encoding="utf-8") + _git(str(wt), "add", *tracked) + _git(str(wt), "commit", "-q", "-m", "seed tracked") + _git(str(wt), "push", "-q", "origin", BRANCH) + + # Dirty tracked + untracked. + for rel in tracked: + (wt / rel).write_text(f"dirty {rel}\n", encoding="utf-8") + untracked = [ + "tests/test_dirty_same_claimant_session_rebind.py", + "docs/runbook-dirty-rebind.md", + "scratch/notes-untracked.txt", + "extra_untracked.txt", + ] + for rel in untracked: + p = wt / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(f"untracked {rel}\n", encoding="utf-8") + + inv = rebind.collect_dirty_inventory(str(wt)) + assert inv["ok"], inv.get("reasons") + head = _git(str(wt), "rev-parse", "HEAD").stdout.strip() + remote_head = _git( + str(wt), "rev-parse", f"refs/remotes/origin/{BRANCH}" + ).stdout.strip() + assert head == remote_head + + return { + "root": str(root), + "main": str(main), + "worktree": str(wt), + "branch": BRANCH, + "inventory": inv, + "local_head": head, + "remote_head": remote_head, + "dirty_paths": list(inv["dirty_paths"]), + "fingerprints": dict(inv["fingerprints"]), + } + + +def _make_lock( + *, + worktree: str, + pid: int, + lock_dir: str, + identity: str = IDENTITY, + profile: str = PROFILE, + **overrides, +) -> dict: + lease = { + "operation_type": ils.AUTHOR_ISSUE_WORK_LEASE, + "issue_number": ISSUE, + "branch": BRANCH, + "worktree_path": worktree, + "claimant": {"username": identity, "profile": profile}, + "created_at": "2026-01-01T00:00:00Z", + "expires_at": future_ts(), + "last_heartbeat_at": "2026-01-01T00:00:00Z", + } + lock = { + "issue_number": ISSUE, + "branch_name": BRANCH, + "worktree_path": worktree, + "remote": REMOTE, + "org": ORG, + "repo": REPO, + "session_pid": pid, + "pid": pid, + "work_lease": lease, + "lock_generation": 1, + "lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance( + tool="gitea_lock_issue", + claimant={"username": identity, "profile": profile}, + ), + } + lock.update(overrides) + path = ils.lock_file_path( + remote=REMOTE, org=ORG, repo=REPO, issue_number=ISSUE, lock_dir=lock_dir + ) + lock["lock_file_path"] = path + ils.save_lock_file(path, lock) + # Stale session pointer for the dead owner. + ptr = { + "pid": pid, + "lock_file_path": path, + "issue_number": ISSUE, + "branch_name": BRANCH, + "remote": REMOTE, + "org": ORG, + "repo": REPO, + } + ils.save_lock_file(os.path.join(lock_dir, f"session-{pid}.json"), ptr) + return ils.read_lock_file(path) or lock + + +def _apply_kwargs(repo, lock, lock_dir, **overrides): + kwargs = { + "remote": REMOTE, + "org": ORG, + "repo": REPO, + "issue_number": ISSUE, + "branch_name": BRANCH, + "worktree_path": repo["worktree"], + "claimant_identity": IDENTITY, + "claimant_profile": PROFILE, + "old_pid": lock.get("session_pid") or lock.get("pid"), + "expected_local_head": repo["local_head"], + "expected_remote_head": repo["remote_head"], + "expected_dirty_paths": repo["dirty_paths"], + "expected_fingerprints": repo["fingerprints"], + "existing_lock": lock, + "current_identity": IDENTITY, + "current_profile": PROFILE, + "role_kind": "author", + "current_pid": os.getpid(), + "current_branch": BRANCH, + "local_head": repo["local_head"], + "remote_head": repo["remote_head"], + "dirty_inventory": repo["inventory"], + "competing_live_locks": [], + "competing_sessions": [], + "workflow_lease_active": False, + "repo_root": repo["root"], + "dry_run": False, + "lock_dir": lock_dir, + } + kwargs.update(overrides) + return kwargs + + +# ── 1. Successful dead-PID same-claimant dirty rebind ─────────────────────── + + +def test_successful_dead_pid_same_claimant_dirty_rebind(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert result["success"], result + assert result["outcome"] == rebind.REBIND_SANCTIONED + assert result["old_pid"] == old + assert result["new_pid"] == os.getpid() + assert result["generation_after"] == result["generation_before"] + 1 + + rebound = ils.read_lock_file(result["lock_path"]) + assert rebound is not None + assert int(rebound["session_pid"]) == os.getpid() + assert int(rebound["pid"]) == os.getpid() + assert ( + rebound.get("lock_provenance", {}).get("source") + == issue_lock_provenance.SOURCE_DIRTY_SAME_CLAIMANT_REBIND + ) + assert rebound.get("rebind_record", {}).get("old_pid") == old + + +# ── 2. Byte-for-byte preservation ─────────────────────────────────────────── + + +def test_byte_for_byte_preservation(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + before = { + rel: rebind.content_fingerprint(os.path.join(dirty_repo["worktree"], rel)) + for rel in dirty_repo["dirty_paths"] + } + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert result["success"], result + after = { + rel: rebind.content_fingerprint(os.path.join(dirty_repo["worktree"], rel)) + for rel in dirty_repo["dirty_paths"] + } + assert before == after + assert result["fingerprints"] == before + + +# ── 3. Exact dirty-path and fingerprint enforcement ───────────────────────── + + +def test_extra_dirty_path_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + pins = list(dirty_repo["dirty_paths"])[:-1] # missing one observed path + fps = {p: dirty_repo["fingerprints"][p] for p in pins} + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + expected_dirty_paths=pins, + expected_fingerprints=fps, + ) + ) + assert not result["success"] + assert any("unexpected paths" in r for r in result["reasons"]) + + +def test_missing_expected_dirty_path_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + pins = list(dirty_repo["dirty_paths"]) + ["not_really_dirty.txt"] + fps = dict(dirty_repo["fingerprints"]) + fps["not_really_dirty.txt"] = "0" * 64 + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + expected_dirty_paths=pins, + expected_fingerprints=fps, + ) + ) + assert not result["success"] + assert any("missing expected" in r for r in result["reasons"]) + + +def test_modified_fingerprint_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + fps = dict(dirty_repo["fingerprints"]) + victim = dirty_repo["dirty_paths"][0] + fps[victim] = "f" * 64 + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + expected_fingerprints=fps, + ) + ) + assert not result["success"] + assert any("fingerprint disagreement" in r for r in result["reasons"]) + + +# ── 4. Atomic session-pointer replacement ─────────────────────────────────── + + +def test_session_pointer_points_to_lock(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert result["success"], result + new_ptr_path = os.path.join(lock_dir, f"session-{os.getpid()}.json") + assert os.path.exists(new_ptr_path) + ptr = ils.read_lock_file(new_ptr_path) + assert ptr is not None + assert os.path.realpath(ptr["lock_file_path"]) == os.path.realpath( + result["lock_path"] + ) + # Old pointer removed when it targeted this lock. + old_ptr = os.path.join(lock_dir, f"session-{old}.json") + assert not os.path.exists(old_ptr) + assert result.get("removed_old_session_pointer") is True + + +# ── 5. Retry after interruption (journal mid-state) ───────────────────────── + + +def test_retry_after_journal_mid_state(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + jpath = rebind.journal_path(lock_dir, ISSUE) + rebind._atomic_write_json( + jpath, + { + "phase": rebind.JOURNAL_PHASE_PRE_BIND, + "issue_number": ISSUE, + "old_pid": old, + "new_pid": os.getpid(), + "expected_generation": 1, + }, + ) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert result["success"], result + assert result["journal_phase"] == rebind.JOURNAL_PHASE_COMPLETE + + # Second apply is already_rebound (retry-safe). + rebound_lock = ils.read_lock_file(result["lock_path"]) + result2 = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + rebound_lock, + lock_dir, + old_pid=old, + existing_lock=rebound_lock, + ) + ) + assert result2["success"], result2 + assert result2["already_rebound"] is True + + +# ── 6. Active-PID refusal ─────────────────────────────────────────────────── + + +def test_active_pid_refused(dirty_repo, lock_dir): + live = os.getpid() + # Use a different "current" identity of session via fake current_pid... + # Owner is live (this process). Rebind must refuse. + lock = _make_lock(worktree=dirty_repo["worktree"], pid=live, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=live, + current_pid=live + 10_000_000, # distinct "new" session id for pin check + ) + ) + assert not result["success"] + assert any("still alive" in r for r in result["reasons"]) + + +# ── 7. Foreign claimant refusal ───────────────────────────────────────────── + + +def test_foreign_claimant_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + current_identity="someone-else", + claimant_identity="someone-else", + ) + ) + assert not result["success"] + assert any("foreign claimant" in r or "does not match" in r for r in result["reasons"]) + + +# ── 8. Profile mismatch refusal ───────────────────────────────────────────── + + +def test_profile_mismatch_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + current_profile="other-profile", + claimant_profile="other-profile", + ) + ) + assert not result["success"] + assert any("profile" in r for r in result["reasons"]) + + +# ── 9. Competing session/lock/lease refusal ───────────────────────────────── + + +def test_competing_live_lock_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + competing = [ + { + "issue_number": ISSUE, + "branch_name": BRANCH, + "worktree_path": dirty_repo["worktree"] + "-other", + "pid": os.getpid(), + } + ] + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + competing_live_locks=competing, + ) + ) + assert not result["success"] + assert any("competing live lock" in r for r in result["reasons"]) + + +def test_competing_session_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + competing_sessions=[ + {"pid": os.getpid(), "lock_file_path": lock["lock_file_path"], "live": True} + ], + ) + ) + # current_pid is os.getpid(), so same session is skipped — use another live pid. + # Spawn a long-lived process to act as competing live session. + rival = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"]) + try: + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + competing_sessions=[ + { + "pid": rival.pid, + "lock_file_path": lock["lock_file_path"], + "live": True, + } + ], + ) + ) + assert not result["success"] + assert any("competing live session" in r for r in result["reasons"]) + finally: + rival.kill() + rival.wait() + + +def test_workflow_lease_active_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + workflow_lease_active=True, + ) + ) + assert not result["success"] + assert any("workflow lease" in r for r in result["reasons"]) + + +# ── 10. Local- and remote-head movement refusal ───────────────────────────── + + +def test_local_head_movement_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + expected_local_head="b" * 40, + ) + ) + assert not result["success"] + assert any("local head" in r for r in result["reasons"]) + + +def test_remote_head_movement_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + expected_remote_head="c" * 40, + ) + ) + assert not result["success"] + assert any("remote head" in r for r in result["reasons"]) + + +# ── 11. Path/symlink/registration mismatches ──────────────────────────────── + + +def test_worktree_not_under_branches_refused(dirty_repo, lock_dir, tmp_path): + old = dead_pid() + # Use a path outside branches/ as the declared worktree (still real dir). + outside = tmp_path / "outside-wt" + outside.mkdir() + lock = _make_lock(worktree=str(outside), pid=old, lock_dir=lock_dir) + # Inventory empty for outside path; use empty pins to hit path gate first + # by providing matching empty-ish inventory after we force path checks. + inv = { + "dirty_paths": dirty_repo["dirty_paths"], + "fingerprints": dirty_repo["fingerprints"], + "ok": True, + "reasons": [], + } + result = rebind.assess_dirty_same_claimant_session_rebind( + remote=REMOTE, + org=ORG, + repo=REPO, + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path=str(outside), + claimant_identity=IDENTITY, + claimant_profile=PROFILE, + old_pid=old, + expected_local_head=dirty_repo["local_head"], + expected_remote_head=dirty_repo["remote_head"], + expected_dirty_paths=dirty_repo["dirty_paths"], + expected_fingerprints=dirty_repo["fingerprints"], + existing_lock=lock, + current_identity=IDENTITY, + current_profile=PROFILE, + role_kind="author", + current_pid=os.getpid(), + current_branch=BRANCH, + local_head=dirty_repo["local_head"], + remote_head=dirty_repo["remote_head"], + dirty_inventory=inv, + repo_root=dirty_repo["root"], + ) + assert not result["rebind_sanctioned"] + assert any("branches/" in r for r in result["reasons"]) + + +def test_lock_worktree_mismatch_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock( + worktree=dirty_repo["worktree"] + "-elsewhere", + pid=old, + lock_dir=lock_dir, + ) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert not result["success"] + assert any("does not match declared" in r for r in result["reasons"]) + + +# ── 12. Malformed lock/session records ────────────────────────────────────── + + +def test_malformed_lock_missing_pid_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + lock.pop("session_pid", None) + lock.pop("pid", None) + ils.save_lock_file(lock["lock_file_path"], lock) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert not result["success"] + assert any("incomplete" in r or "session_pid" in r for r in result["reasons"]) + + +def test_empty_old_pid_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=None) + ) + assert not result["success"] + assert any("old_pid" in r for r in result["reasons"]) + + +def test_reviewer_role_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old, role_kind="reviewer") + ) + assert not result["success"] + assert any("reviewer" in r for r in result["reasons"]) + + +def test_reconciler_without_authorize_refused(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs( + dirty_repo, + lock, + lock_dir, + old_pid=old, + role_kind="reconciler", + authorize_reconciler_execute=False, + ) + ) + assert not result["success"] + assert any("authorize_reconciler_execute" in r for r in result["reasons"]) + + +# ── 13. No duplicate ownership after success or retry ─────────────────────── + + +def test_no_duplicate_ownership_after_success_or_retry(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + r1 = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old) + ) + assert r1["success"], r1 + rebound = ils.read_lock_file(r1["lock_path"]) + r2 = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, rebound, lock_dir, old_pid=old, existing_lock=rebound) + ) + assert r2["success"], r2 + assert r2["already_rebound"] is True + + # Only one durable lock file for this issue; session pointer is current pid. + # Skip session pointers and rebind journals (dotfiles / non-lock records). + matching = [] + for p in ils.iter_lock_files(lock_dir): + name = os.path.basename(p) + if name.startswith(".") or name.startswith("session-"): + continue + rec = ils.read_lock_file(p) + if not rec: + continue + if ( + rec.get("issue_number") == ISSUE + and rec.get("remote") == REMOTE + and rec.get("branch_name") == BRANCH + and rec.get("session_pid") is not None + ): + matching.append(rec) + assert len(matching) == 1 + assert int(matching[0]["session_pid"]) == os.getpid() + # No live session pointer for the dead old pid. + assert not os.path.exists(os.path.join(lock_dir, f"session-{old}.json")) + + +# ── 14. Ordinary dirty-worktree locking remains fail-closed ───────────────── + + +def test_ordinary_dirty_lock_worktree_assessment_blocks(dirty_repo): + porcelain = dirty_repo["inventory"]["porcelain_status"] + assessment = issue_lock_worktree.assess_issue_lock_worktree( + worktree_path=dirty_repo["worktree"], + current_branch=BRANCH, + porcelain_status=porcelain, + base_equivalent=False, + ) + assert assessment["block"] is True + assert any( + "tracked file edits exist before issue lock" in r + for r in assessment["reasons"] + ) + + +# ── 15. Fixture matching #860 class with 7 fingerprint-pinned dirty paths ─── + + +def test_issue_860_regression_fixture_spec(): + spec = rebind.build_issue_860_regression_fixture_spec() + assert spec["claimant_identity"] == "jcwalker3" + assert spec["claimant_profile"] == "prgs-author" + assert spec["old_pid_alive"] is False + assert spec["live_session_pointer"] is None + assert spec["dirty_path_count"] == 7 + assert len(spec["expected_dirty_paths"]) == 7 + assert len(spec["expected_fingerprints"]) == 7 + assert spec["expected_local_head"] == spec["expected_remote_head"] + for path in spec["expected_dirty_paths"]: + assert path in spec["expected_fingerprints"] + assert len(spec["expected_fingerprints"][path]) == 64 + + +def test_dry_run_does_not_write(dirty_repo, lock_dir): + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + before = ils.read_lock_file(lock["lock_file_path"]) + result = rebind.apply_dirty_same_claimant_session_rebind( + **_apply_kwargs(dirty_repo, lock, lock_dir, old_pid=old, dry_run=True) + ) + assert result["success"], result + assert result["dry_run"] is True + after = ils.read_lock_file(lock["lock_file_path"]) + assert after["session_pid"] == before["session_pid"] + assert not os.path.exists(os.path.join(lock_dir, f"session-{os.getpid()}.json")) + + +def test_provenance_source_is_sanctioned(): + assert ( + issue_lock_provenance.SOURCE_DIRTY_SAME_CLAIMANT_REBIND + in issue_lock_provenance.SANCTIONED_LOCK_SOURCES + ) + assessment = issue_lock_provenance.assess_lock_file_for_create_pr( + { + "work_lease": {"operation_type": "author_issue_work"}, + "lock_provenance": { + "source": issue_lock_provenance.SOURCE_DIRTY_SAME_CLAIMANT_REBIND, + "written_by_tool": issue_lock_provenance.SOURCE_DIRTY_SAME_CLAIMANT_REBIND, + "written_at": "2026-01-01T00:00:00Z", + }, + } + ) + assert assessment["proven"] is True + + +def test_permission_allowed_is_not_ownership_proof(dirty_repo, lock_dir): + """permission_allowed=True must not bypass foreign claimant refusal.""" + old = dead_pid() + lock = _make_lock(worktree=dirty_repo["worktree"], pid=old, lock_dir=lock_dir) + result = rebind.assess_dirty_same_claimant_session_rebind( + remote=REMOTE, + org=ORG, + repo=REPO, + issue_number=ISSUE, + branch_name=BRANCH, + worktree_path=dirty_repo["worktree"], + claimant_identity=IDENTITY, + claimant_profile=PROFILE, + old_pid=old, + expected_local_head=dirty_repo["local_head"], + expected_remote_head=dirty_repo["remote_head"], + expected_dirty_paths=dirty_repo["dirty_paths"], + expected_fingerprints=dirty_repo["fingerprints"], + existing_lock=lock, + current_identity="intruder", + current_profile=PROFILE, + role_kind="author", + current_pid=os.getpid(), + current_branch=BRANCH, + local_head=dirty_repo["local_head"], + remote_head=dirty_repo["remote_head"], + dirty_inventory=dirty_repo["inventory"], + permission_allowed=True, + repo_root=dirty_repo["root"], + ) + assert not result["rebind_sanctioned"] + assert any("does not match active identity" in r for r in result["reasons"]) + + +def test_content_fingerprint_stable(tmp_path): + p = tmp_path / "f.txt" + p.write_bytes(b"abc123") + a = rebind.content_fingerprint(str(p)) + b = rebind.content_fingerprint(str(p)) + assert a == b + assert len(a) == 64 -- 2.43.7