fix(mcp): accept strict-descendant dead-session recovery (Closes #768)
Permit fail-closed recovery when a clean local head is a strict descendant of the recorded PR/remote head, with server-side ancestry proof. Propagate recovery evidence through commit, push, and PR duplicate gates so an owning PR is not re-blocked as competing work. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -30,6 +30,108 @@ def resolve_author_worktree_path(
|
||||
return os.path.realpath(os.path.abspath(path))
|
||||
|
||||
|
||||
def read_head_ancestry(
|
||||
worktree_path: str,
|
||||
*,
|
||||
ancestor_sha: str | None,
|
||||
descendant_sha: str | None,
|
||||
) -> dict:
|
||||
"""Observe whether ``descendant_sha`` strictly descends from ``ancestor_sha`` (#768).
|
||||
|
||||
Server-side git observation for dead-session lock recovery. The recovering
|
||||
author's only reachable clean-worktree state is one commit *ahead* of the
|
||||
head recorded at lock time, so recovery needs to know whether that commit
|
||||
extends the recorded head or replaces it.
|
||||
|
||||
Reports facts only; the disposition lives in ``issue_lock_recovery``. Every
|
||||
field is read from git in the declared worktree — nothing here is supplied
|
||||
by, or reachable from, an MCP caller (#768 AC6).
|
||||
|
||||
``ancestor_present`` proves the recorded head is still reachable, which is
|
||||
what separates an honest fast-forward from a rewritten or force-moved
|
||||
history: a rewritten recorded head leaves the object graph and the probe
|
||||
fails closed.
|
||||
"""
|
||||
path = (worktree_path or "").strip()
|
||||
ancestor = (ancestor_sha or "").strip()
|
||||
descendant = (descendant_sha or "").strip()
|
||||
result: dict = {
|
||||
"ancestor_sha": ancestor or None,
|
||||
"descendant_sha": descendant or None,
|
||||
"probe_ok": False,
|
||||
"ancestor_present": False,
|
||||
"descendant_present": False,
|
||||
"is_ancestor": False,
|
||||
"is_strict_descendant": False,
|
||||
"proof": None,
|
||||
"reasons": [],
|
||||
}
|
||||
if not path or not ancestor or not descendant:
|
||||
result["reasons"].append(
|
||||
"ancestry probe requires a worktree path and both commit SHAs"
|
||||
)
|
||||
return result
|
||||
|
||||
def _present(sha: str) -> bool:
|
||||
res = subprocess.run(
|
||||
["git", "-C", path, "rev-parse", "--verify", "--quiet", f"{sha}^{{commit}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return res.returncode == 0
|
||||
|
||||
try:
|
||||
result["ancestor_present"] = _present(ancestor)
|
||||
result["descendant_present"] = _present(descendant)
|
||||
except OSError as exc: # git unavailable — fail closed, never assume
|
||||
result["reasons"].append(f"ancestry probe could not run: {exc}")
|
||||
return result
|
||||
|
||||
if not result["ancestor_present"]:
|
||||
result["reasons"].append(
|
||||
f"recorded head {ancestor} is not reachable in '{path}'; history may "
|
||||
"have been rewritten or force-moved"
|
||||
)
|
||||
if not result["descendant_present"]:
|
||||
result["reasons"].append(
|
||||
f"local head {descendant} is not reachable in '{path}'"
|
||||
)
|
||||
if not (result["ancestor_present"] and result["descendant_present"]):
|
||||
return result
|
||||
|
||||
probe = subprocess.run(
|
||||
["git", "-C", path, "merge-base", "--is-ancestor", ancestor, descendant],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
# 0 = is an ancestor, 1 = is not. Anything else is a failed probe, not a "no".
|
||||
if probe.returncode not in (0, 1):
|
||||
result["reasons"].append(
|
||||
f"ancestry probe failed with exit {probe.returncode}; ancestry unproven"
|
||||
)
|
||||
return result
|
||||
|
||||
result["probe_ok"] = True
|
||||
result["is_ancestor"] = probe.returncode == 0
|
||||
result["is_strict_descendant"] = result["is_ancestor"] and ancestor != descendant
|
||||
result["proof"] = (
|
||||
f"git -C <worktree> merge-base --is-ancestor {ancestor} {descendant} "
|
||||
f"-> exit {probe.returncode}"
|
||||
)
|
||||
if not result["is_ancestor"]:
|
||||
result["reasons"].append(
|
||||
f"local head {descendant} does not descend from recorded head {ancestor}"
|
||||
)
|
||||
elif not result["is_strict_descendant"]:
|
||||
result["reasons"].append(
|
||||
f"local head {descendant} equals the recorded head; no descendant "
|
||||
"recovery is involved"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def read_worktree_git_state(
|
||||
worktree_path: str,
|
||||
extra_bases: tuple[str, ...] | list[str] = (),
|
||||
|
||||
Reference in New Issue
Block a user