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:
+188
-18
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user