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:
2026-07-20 14:18:25 -05:00
co-authored by Claude Opus 4.8 (1M context) <[email protected]>
parent 0c2f45abb7
commit ca76dacd73
5 changed files with 942 additions and 83 deletions
+188 -18
View File
@@ -34,6 +34,33 @@ ancestor, still unmodified — so it is accepted, and nothing else is. The
descendant fact is observed server-side by
``issue_lock_worktree.read_head_ancestry`` and handed in as ``head_ancestry``;
no caller can assert it.
#772 adds the remaining uncovered quadrant: a claim that was never published at
all. Two recovery modes now exist, and they require different evidence because
they are answering the same question against different available facts:
``published_owning_pr``
The branch exists on the remote. Ownership is proven by comparing the local
head against the remote/PR head — equal (#753) or a strict descendant
(#768). This is the pre-existing behavior and is unchanged.
``unpublished_claim``
The branch is absent from the remote and no PR claims it, so there is no
head to compare against; that absence is the defining fact, not a degraded
published case. Ownership is instead proven by the durable lock record
(issue, branch, worktree, claimant, profile, dead PID) plus the local HEAD
strictly descending from the base the branch was cut from, observed
server-side by ``issue_lock_worktree.read_recorded_base`` and re-checked
through ``base_ancestry``.
They cannot share one head-comparison implementation: the published path's
comparison target does not exist in the unpublished case, and inventing one
(defaulting to the base, say) would silently weaken the published path from
"matches what was actually pushed" to "descends from some base". The modes are
therefore selected by observed publication state and never by a caller — and
critically, the absence of a remote head is never itself treated as permission:
every identity, profile, branch, worktree, cleanliness, liveness, and competing
-claim check still applies in full.
"""
from __future__ import annotations
@@ -55,6 +82,18 @@ REQUIRED_LOCK_FIELDS = ("issue_number", "branch_name", "worktree_path")
# How the clean local head relates to the head recorded at lock time (#768).
HEAD_RELATION_EQUAL = "equal"
HEAD_RELATION_STRICT_DESCENDANT = "strict_descendant"
# #772: an unpublished claim has no recorded head to compare against at all, so
# its head is measured against the base the branch was cut from instead.
HEAD_RELATION_DESCENDS_FROM_BASE = "descends_from_recorded_base"
# Which body of evidence a recovery was decided on (#772 AC10). These are not
# interchangeable: a published claim proves ownership against a remote/PR head,
# an unpublished one against the recorded base plus durable lock state. They
# cannot share a single head-comparison implementation because the unpublished
# case has no head to compare — that absence is the defining fact, not a
# degraded version of the published case.
RECOVERY_MODE_PUBLISHED_OWNING_PR = "published_owning_pr"
RECOVERY_MODE_UNPUBLISHED_CLAIM = "unpublished_claim"
def _same_realpath(left: str | None, right: str | None) -> bool:
@@ -165,6 +204,68 @@ def _assess_strict_descendant(
]
def _assess_base_descendancy(
base_ancestry: Mapping[str, Any] | None,
*,
recorded_base: str,
local_head: str,
) -> tuple[bool, list[str]]:
"""Is ``local_head`` a proven strict descendant of ``recorded_base`` (#772)?
The unpublished-claim analogue of ``_assess_strict_descendant``. The
comparison target is the base the branch was cut from — observed server-side
by ``issue_lock_worktree.read_recorded_base`` — rather than a remote or PR
head, because an unpublished claim has neither.
The probe's own endpoints are re-checked against the values under
assessment, so an observation taken for some other pair of commits cannot
authorize recovery. Equality is refused: a HEAD that merely equals its base
carries no committed work, and that is the ordinary base-equivalent case the
normal lock path already handles.
"""
if not isinstance(base_ancestry, Mapping):
return False, [
"no server-derived ancestry observation was available; an "
"unpublished claim cannot be recovered without proving its HEAD "
"descends from the recorded base"
]
probe_ancestor = _text(base_ancestry.get("ancestor_sha"))
probe_descendant = _text(base_ancestry.get("descendant_sha"))
if probe_ancestor != recorded_base or probe_descendant != local_head:
return False, [
f"ancestry observation covers {probe_ancestor or 'unknown'} -> "
f"{probe_descendant or 'unknown'}, not the commits under assessment "
f"({recorded_base} -> {local_head})"
]
if not base_ancestry.get("probe_ok"):
return False, (
list(base_ancestry.get("reasons") or [])
or ["ancestry probe did not complete; ancestry unproven"]
)
if not base_ancestry.get("ancestor_present"):
return False, [
f"recorded base {recorded_base} is no longer reachable; a rewritten "
"or force-moved base cannot be recovered"
]
if not base_ancestry.get("is_strict_descendant"):
return False, (
list(base_ancestry.get("reasons") or [])
or [
f"local head {local_head} is not a strict descendant of the "
f"recorded base {recorded_base}"
]
)
proof = _text(base_ancestry.get("proof")) or (
f"{recorded_base} is an ancestor of {local_head}"
)
return True, [
f"local head {local_head} strictly descends from recorded base "
f"{recorded_base} ({proof})"
]
def assess_dead_session_lock_recovery(
existing_lock: Mapping[str, Any] | None,
*,
@@ -186,6 +287,9 @@ def assess_dead_session_lock_recovery(
candidate_branches: Iterable[str] | None = None,
current_pid: int | None = None,
head_ancestry: Mapping[str, Any] | None = None,
remote_branch_exists: bool | None = None,
recorded_base_sha: str | None = None,
base_ancestry: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Decide whether a dead-session author lock may be natively recovered.
@@ -304,32 +408,73 @@ def assess_dead_session_lock_recovery(
# to reach the clean worktree recovery itself demands.
local_head = _text(head_sha)
remote_head = _text(remote_head_sha)
recorded_base = _text(recorded_base_sha)
head_relation: str | None = None
ancestry_proof: str | None = None
if not local_head:
reasons.append("local head SHA could not be determined")
if not remote_head:
reasons.append(
f"remote head for branch '{locked_branch}' could not be determined"
)
if local_head and remote_head:
if local_head == remote_head:
head_relation = HEAD_RELATION_EQUAL
else:
descends, notes = _assess_strict_descendant(
head_ancestry,
recorded_head=remote_head,
# #772: which body of evidence applies is decided by observed publication
# state, never by a caller. ``remote_branch_exists is False`` is a positive
# server-side observation that the branch is absent from the remote — it is
# not the same as "the head lookup failed", which must still fail closed.
unpublished = remote_branch_exists is False and not remote_head
recovery_mode = (
RECOVERY_MODE_UNPUBLISHED_CLAIM if unpublished
else RECOVERY_MODE_PUBLISHED_OWNING_PR
)
evidence["recovery_mode"] = recovery_mode
evidence["remote_branch_exists"] = remote_branch_exists
if unpublished:
# No remote branch: ownership is measured against the recorded base.
# An open PR here is contradictory — a PR cannot exist without a remote
# branch — so it is a mismatch, never a thing to reconcile.
if _text(pr_head_sha) or pr_number is not None:
reasons.append(
f"branch '{locked_branch}' is absent from the remote yet PR "
f"#{pr_number} claims it; publication state is contradictory"
)
if not recorded_base:
reasons.append(
f"recorded base for branch '{locked_branch}' could not be "
"determined; an unpublished claim cannot be recovered without it"
)
if local_head and recorded_base:
descends, notes = _assess_base_descendancy(
base_ancestry,
recorded_base=recorded_base,
local_head=local_head,
)
if descends:
head_relation = HEAD_RELATION_STRICT_DESCENDANT
head_relation = HEAD_RELATION_DESCENDS_FROM_BASE
ancestry_proof = notes[0] if notes else None
else:
reasons.append(
f"local head {local_head} does not match remote branch head "
f"{remote_head}"
)
reasons.extend(notes)
else:
if not remote_head:
reasons.append(
f"remote head for branch '{locked_branch}' could not be determined"
)
if local_head and remote_head:
if local_head == remote_head:
head_relation = HEAD_RELATION_EQUAL
else:
descends, notes = _assess_strict_descendant(
head_ancestry,
recorded_head=remote_head,
local_head=local_head,
)
if descends:
head_relation = HEAD_RELATION_STRICT_DESCENDANT
ancestry_proof = notes[0] if notes else None
else:
reasons.append(
f"local head {local_head} does not match remote branch head "
f"{remote_head}"
)
reasons.extend(notes)
evidence["recorded_base"] = recorded_base or None
evidence["local_head"] = local_head or None
evidence["remote_head"] = remote_head or None
# ``recorded_head`` is the head recovery is being measured against;
@@ -344,7 +489,9 @@ def assess_dead_session_lock_recovery(
if pr_head:
evidence["pr_head"] = pr_head
evidence["pr_number"] = pr_number
if local_head and pr_head != local_head:
# In unpublished mode the presence of any PR was already refused above as
# contradictory; re-stating it as a head mismatch would only obscure why.
if not unpublished and local_head and pr_head != local_head:
# A descendant recovery has not been published yet, so the open PR
# legitimately still points at the recorded head. Any other
# disagreement is a real mismatch.
@@ -449,12 +596,32 @@ def assess_dead_session_lock_recovery(
if reasons:
return _result(REFUSED, False, reasons, evidence)
# No disposition may be granted without a proven head relation. Every path
# above that leaves it unset also records a reason, so this is a belt-and-
# braces guard against a future path forgetting one (#772 AC4).
if head_relation is None:
return _result(
REFUSED,
False,
["head relation to the recorded head or base was never proven"],
evidence,
)
proof = [
f"durable lock for issue #{issue_number} matches branch "
f"'{locked_branch}', worktree '{locked_worktree}', head {local_head}, "
f"and claimant '{locked_identity}'; recorded pid {recorded_pid} is dead"
]
if head_relation == HEAD_RELATION_STRICT_DESCENDANT and ancestry_proof:
if recovery_mode == RECOVERY_MODE_UNPUBLISHED_CLAIM:
proof.append(
f"branch '{locked_branch}' has no remote head and no open PR; "
f"ownership proven against recorded base {recorded_base}"
)
if (
head_relation
in (HEAD_RELATION_STRICT_DESCENDANT, HEAD_RELATION_DESCENDS_FROM_BASE)
and ancestry_proof
):
proof.append(ancestry_proof)
return _result(RECOVERY_SANCTIONED, True, proof, evidence)
@@ -639,6 +806,9 @@ def build_recovery_record(
"prior_pid_alive": evidence.get("prior_pid_alive"),
"branch_name": evidence.get("locked_branch"),
"worktree_path": evidence.get("locked_worktree_path"),
"recovery_mode": evidence.get("recovery_mode"),
"remote_branch_exists": evidence.get("remote_branch_exists"),
"recorded_base": evidence.get("recorded_base"),
"local_head": evidence.get("local_head"),
"remote_head": evidence.get("remote_head"),
"recorded_head": evidence.get("recorded_head"),