fix(mcp): recover clean unpublished author work after the owning session exits
Closes #772 Recovery now dispatches on observed publication state: published_owning_pr (remote branch exists; head equality #753 or strict descendancy #768, unchanged) and unpublished_claim (no remote branch, no PR; ownership proven by the durable lock record plus a local HEAD strictly descending from the server-observed base the branch was cut from). The absence of a remote head is never itself permission. Recovery writes are compare-and-swap against a lock generation, and the mutating lock path and read-only diagnostic assessor share one evaluator. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -145,6 +145,83 @@ def read_head_ancestry(
|
||||
return result
|
||||
|
||||
|
||||
def read_recorded_base(
|
||||
worktree_path: str,
|
||||
*,
|
||||
head_sha: str | None,
|
||||
extra_bases: tuple[str, ...] | list[str] = (),
|
||||
base_branches: frozenset[str] | None = None,
|
||||
) -> dict:
|
||||
"""Observe the base commit an unpublished claim was branched from (#772).
|
||||
|
||||
A published claim records its base implicitly: the remote branch head is the
|
||||
thing recovery measures against. An unpublished claim has no remote ref, so
|
||||
the base must be observed here, server-side, as the merge-base between the
|
||||
worktree HEAD and the base branch it was cut from.
|
||||
|
||||
Reports facts only; the disposition lives in ``issue_lock_recovery``. Every
|
||||
field is read from git in the declared worktree — nothing is supplied by, or
|
||||
reachable from, an MCP caller, so a caller cannot nominate a base that would
|
||||
make unrelated history look like a descendant (#772 AC1/AC4).
|
||||
|
||||
A HEAD with no common ancestor in any base branch yields ``probe_ok`` with no
|
||||
``base_sha``: unrelated history is reported as exactly that, never as a base.
|
||||
"""
|
||||
path = (worktree_path or "").strip()
|
||||
head = (head_sha or "").strip()
|
||||
bases = base_branches or BASE_BRANCHES
|
||||
candidates = [*extra_bases, *sorted(bases)]
|
||||
result: dict = {
|
||||
"base_branch": None,
|
||||
"base_sha": None,
|
||||
"head_sha": head or None,
|
||||
"probe_ok": False,
|
||||
"candidates": candidates,
|
||||
"reasons": [],
|
||||
}
|
||||
if not path or not head:
|
||||
result["reasons"].append(
|
||||
"recorded-base probe requires a worktree path and a HEAD sha"
|
||||
)
|
||||
return result
|
||||
|
||||
probed_any = False
|
||||
for candidate in candidates:
|
||||
name = (candidate or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
probe = subprocess.run(
|
||||
["git", "-C", path, "merge-base", name, head],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if probe.returncode not in (0, 1):
|
||||
# 0 = merge base found, 1 = no common ancestor. Anything else is a
|
||||
# failed probe (missing ref, broken repo) — try the next candidate.
|
||||
continue
|
||||
probed_any = True
|
||||
merge_base = (probe.stdout or "").strip()
|
||||
if probe.returncode == 0 and merge_base:
|
||||
result["base_branch"] = name
|
||||
result["base_sha"] = merge_base
|
||||
result["probe_ok"] = True
|
||||
return result
|
||||
|
||||
result["probe_ok"] = probed_any
|
||||
if probed_any:
|
||||
result["reasons"].append(
|
||||
f"HEAD {head} shares no common ancestor with any of "
|
||||
f"{_base_list(bases)}; history is unrelated to this repository's base"
|
||||
)
|
||||
else:
|
||||
result["reasons"].append(
|
||||
f"recorded-base probe could not run against any of {_base_list(bases)} "
|
||||
f"in '{path}'"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def read_worktree_git_state(
|
||||
worktree_path: str,
|
||||
extra_bases: tuple[str, ...] | list[str] = (),
|
||||
|
||||
Reference in New Issue
Block a user