Merge pull request 'fix(mcp): accept strict-descendant dead-session recovery (Closes #768)' (#769) from fix/issue-768-descendant-recovery into master
This commit was merged in pull request #769.
This commit is contained in:
+54
-1
@@ -2273,7 +2273,14 @@ def _enforce_locked_issue_duplicate_recheck(
|
|||||||
org: str | None = None,
|
org: str | None = None,
|
||||||
repo: str | None = None,
|
repo: str | None = None,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""Re-check duplicate-work gates for the locked issue (#400)."""
|
"""Re-check duplicate-work gates for the locked issue (#400).
|
||||||
|
|
||||||
|
#768 AC2: when the lock being re-checked carries a sanctioned dead-session
|
||||||
|
recovery, carry that server-derived owning-PR evidence into the gate. The
|
||||||
|
commit and create-PR phases run in their own calls, long after the recovery
|
||||||
|
assessment ended, so without this they re-block the very PR the recovery
|
||||||
|
already proved belongs to this author.
|
||||||
|
"""
|
||||||
lock_data = _load_existing_issue_lock()
|
lock_data = _load_existing_issue_lock()
|
||||||
if not lock_data:
|
if not lock_data:
|
||||||
return None
|
return None
|
||||||
@@ -2296,6 +2303,9 @@ def _enforce_locked_issue_duplicate_recheck(
|
|||||||
auth=auth,
|
auth=auth,
|
||||||
locked_branch=locked_branch,
|
locked_branch=locked_branch,
|
||||||
phase=phase,
|
phase=phase,
|
||||||
|
recovered_owning_pr=issue_lock_recovery.recovered_owning_pr_from_lock(
|
||||||
|
lock_data
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if gate.get("block"):
|
if gate.get("block"):
|
||||||
return gate
|
return gate
|
||||||
@@ -3402,6 +3412,24 @@ def gitea_lock_issue(
|
|||||||
recovery_pr_number = pull.get("number")
|
recovery_pr_number = pull.get("number")
|
||||||
break
|
break
|
||||||
recovery_claimant = _work_lease_claimant(h)
|
recovery_claimant = _work_lease_claimant(h)
|
||||||
|
# #768: the recovering author's clean worktree is, by construction, one
|
||||||
|
# commit ahead of the head recorded at lock time — committing is the
|
||||||
|
# only sanctioned way to clean it without discarding the work. Observe
|
||||||
|
# the ancestry here, server-side, so the assessor can tell an honest
|
||||||
|
# fast-forward from a rewritten head. Probed only when the heads
|
||||||
|
# actually differ, and never from any caller-supplied value.
|
||||||
|
recovery_local_head = git_state.get("head_sha")
|
||||||
|
recovery_ancestry: dict | None = None
|
||||||
|
if (
|
||||||
|
recovery_remote_head
|
||||||
|
and recovery_local_head
|
||||||
|
and recovery_remote_head != recovery_local_head
|
||||||
|
):
|
||||||
|
recovery_ancestry = issue_lock_worktree.read_head_ancestry(
|
||||||
|
resolved_worktree,
|
||||||
|
ancestor_sha=recovery_remote_head,
|
||||||
|
descendant_sha=recovery_local_head,
|
||||||
|
)
|
||||||
recovery_assessment = issue_lock_recovery.assess_dead_session_lock_recovery(
|
recovery_assessment = issue_lock_recovery.assess_dead_session_lock_recovery(
|
||||||
existing_issue_lock,
|
existing_issue_lock,
|
||||||
issue_number=issue_number,
|
issue_number=issue_number,
|
||||||
@@ -3421,6 +3449,7 @@ def gitea_lock_issue(
|
|||||||
competing_live_locks=issue_lock_store.list_live_locks(),
|
competing_live_locks=issue_lock_store.list_live_locks(),
|
||||||
candidate_branches=recovery_candidates,
|
candidate_branches=recovery_candidates,
|
||||||
current_pid=os.getpid(),
|
current_pid=os.getpid(),
|
||||||
|
head_ancestry=recovery_ancestry,
|
||||||
)
|
)
|
||||||
|
|
||||||
recovery_sanctioned = bool(
|
recovery_sanctioned = bool(
|
||||||
@@ -3628,6 +3657,15 @@ def gitea_assess_work_issue_duplicate(
|
|||||||
}
|
}
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
|
# #768 AC2: recovery evidence comes only from the durable lock this session
|
||||||
|
# already holds, and only when that lock is for the issue being assessed.
|
||||||
|
# Nothing here is reachable through this tool's parameters.
|
||||||
|
recovered_owning_pr = None
|
||||||
|
lock_data = _load_existing_issue_lock()
|
||||||
|
if lock_data and int(lock_data.get("issue_number") or 0) == int(issue_number):
|
||||||
|
recovered_owning_pr = issue_lock_recovery.recovered_owning_pr_from_lock(
|
||||||
|
lock_data
|
||||||
|
)
|
||||||
gate = _assess_issue_duplicate_gate(
|
gate = _assess_issue_duplicate_gate(
|
||||||
issue_number,
|
issue_number,
|
||||||
h=h,
|
h=h,
|
||||||
@@ -3636,6 +3674,7 @@ def gitea_assess_work_issue_duplicate(
|
|||||||
auth=auth,
|
auth=auth,
|
||||||
locked_branch=branch_name,
|
locked_branch=branch_name,
|
||||||
phase=phase,
|
phase=phase,
|
||||||
|
recovered_owning_pr=recovered_owning_pr,
|
||||||
)
|
)
|
||||||
return {"success": not gate.get("block"), **gate}
|
return {"success": not gate.get("block"), **gate}
|
||||||
|
|
||||||
@@ -16115,6 +16154,16 @@ def _prove_author_ownership_for_pr(
|
|||||||
"durable lock, or branch lock (fail closed; issue_number need not "
|
"durable lock, or branch lock (fail closed; issue_number need not "
|
||||||
"equal pr_number when the tracking issue is linked)"
|
"equal pr_number when the tracking issue is linked)"
|
||||||
)
|
)
|
||||||
|
# #768 AC2: when the lock that proved ownership was itself a sanctioned
|
||||||
|
# dead-session recovery, carry that server-derived evidence into the push
|
||||||
|
# gate rather than making it re-derive ownership blind. ``recorded_head``
|
||||||
|
# and ``accepted_head`` let the caller see that the PR head it is about to
|
||||||
|
# advance is the one recovery already sanctioned, not a foreign head.
|
||||||
|
recovered_owning_pr = None
|
||||||
|
if proven and lock_record:
|
||||||
|
candidate = issue_lock_recovery.recovered_owning_pr_from_lock(lock_record)
|
||||||
|
if candidate and int(candidate.get("pr_number") or 0) == int(pr_number):
|
||||||
|
recovered_owning_pr = candidate
|
||||||
return {
|
return {
|
||||||
"proven": proven,
|
"proven": proven,
|
||||||
"has_author_lock": proven,
|
"has_author_lock": proven,
|
||||||
@@ -16123,6 +16172,7 @@ def _prove_author_ownership_for_pr(
|
|||||||
"matched_issue": matched_issue,
|
"matched_issue": matched_issue,
|
||||||
"matched_via": matched_via,
|
"matched_via": matched_via,
|
||||||
"lock_record": lock_record,
|
"lock_record": lock_record,
|
||||||
|
"recovered_owning_pr": recovered_owning_pr,
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16585,6 +16635,9 @@ def gitea_update_pr_branch_by_merge(
|
|||||||
"linked_issues": ownership.get("linked_issues"),
|
"linked_issues": ownership.get("linked_issues"),
|
||||||
"matched_issue": ownership.get("matched_issue"),
|
"matched_issue": ownership.get("matched_issue"),
|
||||||
"matched_via": ownership.get("matched_via"),
|
"matched_via": ownership.get("matched_via"),
|
||||||
|
# #768 AC2: present only when a sanctioned dead-session recovery already
|
||||||
|
# proved this exact PR belongs to this author.
|
||||||
|
"recovered_owning_pr": ownership.get("recovered_owning_pr"),
|
||||||
}
|
}
|
||||||
preflight["host"] = h
|
preflight["host"] = h
|
||||||
preflight["org"] = o
|
preflight["org"] = o
|
||||||
|
|||||||
+238
-24
@@ -22,6 +22,18 @@ by the caller. It performs no mutation and no network I/O.
|
|||||||
Recovery deliberately does **not** relax base-equivalence for brand-new issue
|
Recovery deliberately does **not** relax base-equivalence for brand-new issue
|
||||||
claims — only for a lock whose own prior record already proves the branch,
|
claims — only for a lock whose own prior record already proves the branch,
|
||||||
worktree, head, and author.
|
worktree, head, and author.
|
||||||
|
|
||||||
|
#768 extends the head requirement from strict equality to "equal, or a strict
|
||||||
|
descendant". Equality alone made remediation after a session death unreachable:
|
||||||
|
recovery needs a clean worktree, the only sanctioned way to clean one without
|
||||||
|
discarding work is to commit, and committing advances the head past the value
|
||||||
|
recorded at lock time. A commit that strictly descends from the recorded head,
|
||||||
|
on the same branch, in the same worktree, by the same claimant, preserves
|
||||||
|
everything equality protected — the recorded head is still reachable, still an
|
||||||
|
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -40,6 +52,10 @@ REFUSED = "REFUSED"
|
|||||||
# Durable fields a lock must carry before it can be considered at all.
|
# Durable fields a lock must carry before it can be considered at all.
|
||||||
REQUIRED_LOCK_FIELDS = ("issue_number", "branch_name", "worktree_path")
|
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"
|
||||||
|
|
||||||
|
|
||||||
def _same_realpath(left: str | None, right: str | None) -> bool:
|
def _same_realpath(left: str | None, right: str | None) -> bool:
|
||||||
if not left or not right:
|
if not left or not right:
|
||||||
@@ -87,6 +103,68 @@ def _malformed_reasons(lock: Mapping[str, Any]) -> list[str]:
|
|||||||
return missing
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
def _assess_strict_descendant(
|
||||||
|
head_ancestry: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
recorded_head: str,
|
||||||
|
local_head: str,
|
||||||
|
) -> tuple[bool, list[str]]:
|
||||||
|
"""Is ``local_head`` a proven strict descendant of ``recorded_head`` (#768)?
|
||||||
|
|
||||||
|
``head_ancestry`` is the server-side git observation from
|
||||||
|
``issue_lock_worktree.read_head_ancestry``. Its own ``ancestor_sha`` /
|
||||||
|
``descendant_sha`` are re-checked against the heads this assessment is
|
||||||
|
actually reasoning about, so a probe taken for some other pair of commits —
|
||||||
|
stale, mismatched, or hand-built — can never authorize a waiver.
|
||||||
|
|
||||||
|
Returns ``(proven, notes)``. Notes name the exact missing element so a
|
||||||
|
refused caller sees why, never a bare "unproven".
|
||||||
|
"""
|
||||||
|
if not isinstance(head_ancestry, Mapping):
|
||||||
|
return False, [
|
||||||
|
"no server-derived ancestry observation was available; a local head "
|
||||||
|
"that differs from the recorded head cannot be accepted"
|
||||||
|
]
|
||||||
|
|
||||||
|
notes: list[str] = []
|
||||||
|
probe_ancestor = _text(head_ancestry.get("ancestor_sha"))
|
||||||
|
probe_descendant = _text(head_ancestry.get("descendant_sha"))
|
||||||
|
if probe_ancestor != recorded_head or probe_descendant != local_head:
|
||||||
|
return False, [
|
||||||
|
f"ancestry observation covers {probe_ancestor or 'unknown'} -> "
|
||||||
|
f"{probe_descendant or 'unknown'}, not the heads under assessment "
|
||||||
|
f"({recorded_head} -> {local_head})"
|
||||||
|
]
|
||||||
|
if not head_ancestry.get("probe_ok"):
|
||||||
|
notes.extend(
|
||||||
|
list(head_ancestry.get("reasons") or [])
|
||||||
|
or ["ancestry probe did not complete; ancestry unproven"]
|
||||||
|
)
|
||||||
|
return False, notes
|
||||||
|
if not head_ancestry.get("ancestor_present"):
|
||||||
|
return False, [
|
||||||
|
f"recorded head {recorded_head} is no longer reachable; a rewritten "
|
||||||
|
"or force-moved head cannot be recovered"
|
||||||
|
]
|
||||||
|
if not head_ancestry.get("is_strict_descendant"):
|
||||||
|
notes.extend(
|
||||||
|
list(head_ancestry.get("reasons") or [])
|
||||||
|
or [
|
||||||
|
f"local head {local_head} is not a strict descendant of the "
|
||||||
|
f"recorded head {recorded_head}"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return False, notes
|
||||||
|
|
||||||
|
proof = _text(head_ancestry.get("proof")) or (
|
||||||
|
f"{recorded_head} is an ancestor of {local_head}"
|
||||||
|
)
|
||||||
|
return True, [
|
||||||
|
f"local head {local_head} strictly descends from recorded head "
|
||||||
|
f"{recorded_head} ({proof})"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def assess_dead_session_lock_recovery(
|
def assess_dead_session_lock_recovery(
|
||||||
existing_lock: Mapping[str, Any] | None,
|
existing_lock: Mapping[str, Any] | None,
|
||||||
*,
|
*,
|
||||||
@@ -107,6 +185,7 @@ def assess_dead_session_lock_recovery(
|
|||||||
competing_live_locks: Sequence[Mapping[str, Any]] | None = None,
|
competing_live_locks: Sequence[Mapping[str, Any]] | None = None,
|
||||||
candidate_branches: Iterable[str] | None = None,
|
candidate_branches: Iterable[str] | None = None,
|
||||||
current_pid: int | None = None,
|
current_pid: int | None = None,
|
||||||
|
head_ancestry: Mapping[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Decide whether a dead-session author lock may be natively recovered.
|
"""Decide whether a dead-session author lock may be natively recovered.
|
||||||
|
|
||||||
@@ -218,31 +297,66 @@ def assess_dead_session_lock_recovery(
|
|||||||
)
|
)
|
||||||
evidence["dirty_files"] = dirty_files
|
evidence["dirty_files"] = dirty_files
|
||||||
|
|
||||||
# ── Head agreement: local == remote == PR ───────────────────────────────
|
# ── Head agreement: local is the recorded head, or strictly descends it ──
|
||||||
|
# The recorded head is what the remote branch still carries. A local head
|
||||||
|
# equal to it is the #753 case. A local head that strictly descends from it
|
||||||
|
# is the #768 case: the author committed remediation, which is the only way
|
||||||
|
# to reach the clean worktree recovery itself demands.
|
||||||
local_head = _text(head_sha)
|
local_head = _text(head_sha)
|
||||||
remote_head = _text(remote_head_sha)
|
remote_head = _text(remote_head_sha)
|
||||||
|
head_relation: str | None = None
|
||||||
|
ancestry_proof: str | None = None
|
||||||
if not local_head:
|
if not local_head:
|
||||||
reasons.append("local head SHA could not be determined")
|
reasons.append("local head SHA could not be determined")
|
||||||
if not remote_head:
|
if not remote_head:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
f"remote head for branch '{locked_branch}' could not be determined"
|
f"remote head for branch '{locked_branch}' could not be determined"
|
||||||
)
|
)
|
||||||
if local_head and remote_head and local_head != remote_head:
|
if local_head and remote_head:
|
||||||
reasons.append(
|
if local_head == remote_head:
|
||||||
f"local head {local_head} does not match remote branch 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["local_head"] = local_head or None
|
evidence["local_head"] = local_head or None
|
||||||
evidence["remote_head"] = remote_head or None
|
evidence["remote_head"] = remote_head or None
|
||||||
|
# ``recorded_head`` is the head recovery is being measured against;
|
||||||
|
# ``accepted_head`` is the head this recovery actually adopts. They differ
|
||||||
|
# only in the descendant case, and downstream gates need both (#768 AC2/AC7).
|
||||||
|
evidence["recorded_head"] = remote_head or None
|
||||||
|
evidence["accepted_head"] = local_head or None
|
||||||
|
evidence["head_relation"] = head_relation
|
||||||
|
evidence["ancestry_proof"] = ancestry_proof
|
||||||
|
|
||||||
pr_head = _text(pr_head_sha)
|
pr_head = _text(pr_head_sha)
|
||||||
if pr_head:
|
if pr_head:
|
||||||
evidence["pr_head"] = pr_head
|
evidence["pr_head"] = pr_head
|
||||||
evidence["pr_number"] = pr_number
|
evidence["pr_number"] = pr_number
|
||||||
if local_head and pr_head != local_head:
|
if local_head and pr_head != local_head:
|
||||||
reasons.append(
|
# A descendant recovery has not been published yet, so the open PR
|
||||||
f"open PR #{pr_number} head {pr_head} does not match local head "
|
# legitimately still points at the recorded head. Any other
|
||||||
f"{local_head}"
|
# disagreement is a real mismatch.
|
||||||
)
|
if not (
|
||||||
|
head_relation == HEAD_RELATION_STRICT_DESCENDANT
|
||||||
|
and remote_head
|
||||||
|
and pr_head == remote_head
|
||||||
|
):
|
||||||
|
reasons.append(
|
||||||
|
f"open PR #{pr_number} head {pr_head} does not match local head "
|
||||||
|
f"{local_head}"
|
||||||
|
)
|
||||||
|
|
||||||
# ── Author identity ─────────────────────────────────────────────────────
|
# ── Author identity ─────────────────────────────────────────────────────
|
||||||
claimant = _lock_claimant(lock)
|
claimant = _lock_claimant(lock)
|
||||||
@@ -335,16 +449,14 @@ def assess_dead_session_lock_recovery(
|
|||||||
if reasons:
|
if reasons:
|
||||||
return _result(REFUSED, False, reasons, evidence)
|
return _result(REFUSED, False, reasons, evidence)
|
||||||
|
|
||||||
return _result(
|
proof = [
|
||||||
RECOVERY_SANCTIONED,
|
f"durable lock for issue #{issue_number} matches branch "
|
||||||
True,
|
f"'{locked_branch}', worktree '{locked_worktree}', head {local_head}, "
|
||||||
[
|
f"and claimant '{locked_identity}'; recorded pid {recorded_pid} is dead"
|
||||||
f"durable lock for issue #{issue_number} matches branch "
|
]
|
||||||
f"'{locked_branch}', worktree '{locked_worktree}', head {local_head}, "
|
if head_relation == HEAD_RELATION_STRICT_DESCENDANT and ancestry_proof:
|
||||||
f"and claimant '{locked_identity}'; recorded pid {recorded_pid} is dead"
|
proof.append(ancestry_proof)
|
||||||
],
|
return _result(RECOVERY_SANCTIONED, True, proof, evidence)
|
||||||
evidence,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _result(
|
def _result(
|
||||||
@@ -374,10 +486,16 @@ def owning_pr_recovery_evidence(
|
|||||||
lock already owns" apart from "a competing duplicate PR".
|
lock already owns" apart from "a competing duplicate PR".
|
||||||
|
|
||||||
Returns ``None`` unless recovery was actually granted and the assessment's
|
Returns ``None`` unless recovery was actually granted and the assessment's
|
||||||
own evidence names exactly one owning PR whose head agrees with the local
|
own evidence names exactly one owning PR whose head agrees with the heads
|
||||||
and remote heads. Nothing here is caller-supplied: every field is copied
|
the assessor accepted. Nothing here is caller-supplied: every field is
|
||||||
from evidence the assessor built out of durable lock state plus live
|
copied from evidence the assessor built out of durable lock state plus live
|
||||||
git/Gitea observation, so a caller cannot manufacture an exemption.
|
git/Gitea observation, so a caller cannot manufacture an exemption.
|
||||||
|
|
||||||
|
#768: a descendant recovery carries two heads. ``head_sha`` stays the head
|
||||||
|
the open PR currently shows (the recorded head, since the remediation is not
|
||||||
|
published yet) and ``accepted_head`` is the local descendant that
|
||||||
|
publication will move it to. Downstream gates accept either, so the
|
||||||
|
exemption survives the very push it exists to permit.
|
||||||
"""
|
"""
|
||||||
if not isinstance(assessment, Mapping):
|
if not isinstance(assessment, Mapping):
|
||||||
return None
|
return None
|
||||||
@@ -391,13 +509,28 @@ def owning_pr_recovery_evidence(
|
|||||||
pr_head = _text(evidence.get("pr_head"))
|
pr_head = _text(evidence.get("pr_head"))
|
||||||
local_head = _text(evidence.get("local_head"))
|
local_head = _text(evidence.get("local_head"))
|
||||||
remote_head = _text(evidence.get("remote_head"))
|
remote_head = _text(evidence.get("remote_head"))
|
||||||
|
recorded_head = _text(evidence.get("recorded_head")) or remote_head
|
||||||
|
accepted_head = _text(evidence.get("accepted_head")) or local_head
|
||||||
|
relation = _text(evidence.get("head_relation")) or HEAD_RELATION_EQUAL
|
||||||
raw_pr_number = evidence.get("pr_number")
|
raw_pr_number = evidence.get("pr_number")
|
||||||
|
|
||||||
if raw_pr_number is None or not branch_name or not pr_head:
|
if raw_pr_number is None or not branch_name or not pr_head:
|
||||||
return None
|
return None
|
||||||
# The assessor already required these to agree. Re-check, so a truncated or
|
# The assessor already required these to agree. Re-check, so a truncated or
|
||||||
# hand-built evidence map can never authorize an exemption.
|
# hand-built evidence map can never authorize an exemption.
|
||||||
if pr_head != local_head or pr_head != remote_head:
|
if relation == HEAD_RELATION_EQUAL:
|
||||||
|
if pr_head != local_head or pr_head != remote_head:
|
||||||
|
return None
|
||||||
|
elif relation == HEAD_RELATION_STRICT_DESCENDANT:
|
||||||
|
# The PR must still be at the recorded head, and the accepted head must
|
||||||
|
# actually be a different commit — otherwise this is not a descendant.
|
||||||
|
if not recorded_head or pr_head != recorded_head:
|
||||||
|
return None
|
||||||
|
if not accepted_head or accepted_head == recorded_head:
|
||||||
|
return None
|
||||||
|
if accepted_head != local_head:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
pr_number = int(raw_pr_number)
|
pr_number = int(raw_pr_number)
|
||||||
@@ -410,6 +543,77 @@ def owning_pr_recovery_evidence(
|
|||||||
"pr_number": pr_number,
|
"pr_number": pr_number,
|
||||||
"branch_name": branch_name,
|
"branch_name": branch_name,
|
||||||
"head_sha": pr_head,
|
"head_sha": pr_head,
|
||||||
|
"recorded_head": recorded_head or None,
|
||||||
|
"accepted_head": accepted_head or None,
|
||||||
|
"head_relation": relation,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def recovered_owning_pr_from_lock(
|
||||||
|
lock_record: Mapping[str, Any] | None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Rebuild owning-PR recovery evidence from a persisted lock (#768 AC2).
|
||||||
|
|
||||||
|
``gitea_lock_issue`` holds the live assessment only for the duration of the
|
||||||
|
lock call. The commit, push, create-PR, and duplicate-assessment gates run
|
||||||
|
later, in their own calls, and re-derive ownership from scratch — so an open
|
||||||
|
PR that recovery already proved belongs to this author reappears there as
|
||||||
|
competing duplicate work.
|
||||||
|
|
||||||
|
This reads the same proof back out of the durable ``dead_session_recovery``
|
||||||
|
block that only the server writes, on a lock the caller must already own.
|
||||||
|
It is a re-read of server-derived state, not a new assertion: a caller that
|
||||||
|
could forge this could equally forge the lock file itself, which every other
|
||||||
|
ownership gate already treats as authoritative.
|
||||||
|
"""
|
||||||
|
if not isinstance(lock_record, Mapping):
|
||||||
|
return None
|
||||||
|
record = lock_record.get("dead_session_recovery")
|
||||||
|
if not isinstance(record, Mapping) or not record.get("recovered"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
branch_name = _text(record.get("branch_name")) or _text(
|
||||||
|
lock_record.get("branch_name")
|
||||||
|
)
|
||||||
|
pr_head = _text(record.get("pr_head"))
|
||||||
|
recorded_head = _text(record.get("recorded_head")) or _text(
|
||||||
|
record.get("remote_head")
|
||||||
|
)
|
||||||
|
accepted_head = _text(record.get("accepted_head")) or _text(
|
||||||
|
record.get("local_head")
|
||||||
|
)
|
||||||
|
relation = _text(record.get("head_relation")) or HEAD_RELATION_EQUAL
|
||||||
|
raw_pr_number = record.get("pr_number")
|
||||||
|
raw_issue_number = lock_record.get("issue_number")
|
||||||
|
|
||||||
|
if raw_pr_number is None or raw_issue_number is None:
|
||||||
|
return None
|
||||||
|
if not branch_name or not pr_head:
|
||||||
|
return None
|
||||||
|
if relation == HEAD_RELATION_EQUAL:
|
||||||
|
if accepted_head and accepted_head != pr_head:
|
||||||
|
return None
|
||||||
|
elif relation == HEAD_RELATION_STRICT_DESCENDANT:
|
||||||
|
if not recorded_head or pr_head != recorded_head:
|
||||||
|
return None
|
||||||
|
if not accepted_head or accepted_head == recorded_head:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
pr_number = int(raw_pr_number)
|
||||||
|
issue_number = int(raw_issue_number)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"issue_number": issue_number,
|
||||||
|
"pr_number": pr_number,
|
||||||
|
"branch_name": branch_name,
|
||||||
|
"head_sha": pr_head,
|
||||||
|
"recorded_head": recorded_head or None,
|
||||||
|
"accepted_head": accepted_head or None,
|
||||||
|
"head_relation": relation,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -418,7 +622,13 @@ def build_recovery_record(
|
|||||||
*,
|
*,
|
||||||
recovered_at: str,
|
recovered_at: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Durable, secret-free provenance for a completed recovery (#753 AC2/AC6)."""
|
"""Durable, secret-free provenance for a completed recovery (#753 AC2/AC6).
|
||||||
|
|
||||||
|
#768 AC7: a granted recovery records, atomically with the lock itself, both
|
||||||
|
session identities, the head it was measured against, the head it adopted,
|
||||||
|
how those two relate, and the ancestry proof — so a descendant recovery can
|
||||||
|
be audited after the fact without re-running any probe.
|
||||||
|
"""
|
||||||
evidence = dict(assessment.get("evidence") or {})
|
evidence = dict(assessment.get("evidence") or {})
|
||||||
return {
|
return {
|
||||||
"recovered": True,
|
"recovered": True,
|
||||||
@@ -431,6 +641,10 @@ def build_recovery_record(
|
|||||||
"worktree_path": evidence.get("locked_worktree_path"),
|
"worktree_path": evidence.get("locked_worktree_path"),
|
||||||
"local_head": evidence.get("local_head"),
|
"local_head": evidence.get("local_head"),
|
||||||
"remote_head": evidence.get("remote_head"),
|
"remote_head": evidence.get("remote_head"),
|
||||||
|
"recorded_head": evidence.get("recorded_head"),
|
||||||
|
"accepted_head": evidence.get("accepted_head"),
|
||||||
|
"head_relation": evidence.get("head_relation"),
|
||||||
|
"ancestry_proof": evidence.get("ancestry_proof"),
|
||||||
"pr_head": evidence.get("pr_head"),
|
"pr_head": evidence.get("pr_head"),
|
||||||
"pr_number": evidence.get("pr_number"),
|
"pr_number": evidence.get("pr_number"),
|
||||||
"identity": evidence.get("locked_identity"),
|
"identity": evidence.get("locked_identity"),
|
||||||
|
|||||||
@@ -30,6 +30,108 @@ def resolve_author_worktree_path(
|
|||||||
return os.path.realpath(os.path.abspath(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(
|
def read_worktree_git_state(
|
||||||
worktree_path: str,
|
worktree_path: str,
|
||||||
extra_bases: tuple[str, ...] | list[str] = (),
|
extra_bases: tuple[str, ...] | list[str] = (),
|
||||||
|
|||||||
@@ -122,18 +122,30 @@ def _assess_owning_pr_exemption(
|
|||||||
f"the recovered branch '{token_branch}' (no owning-PR exemption)"
|
f"the recovered branch '{token_branch}' (no owning-PR exemption)"
|
||||||
)
|
)
|
||||||
return False, notes
|
return False, notes
|
||||||
if not token_head or not only_sha or only_sha != token_head:
|
# #768: a descendant recovery is measured against the head the PR still
|
||||||
|
# shows, then publishes the local descendant — so the live PR head is the
|
||||||
|
# recorded head before that push and the accepted head after it. Both are
|
||||||
|
# server-derived and name the same owned PR, so both are accepted; anything
|
||||||
|
# else still fails closed.
|
||||||
|
token_accepted = str(recovered_owning_pr.get("accepted_head") or "").strip()
|
||||||
|
acceptable_heads = [head for head in (token_head, token_accepted) if head]
|
||||||
|
if not acceptable_heads or not only_sha or only_sha not in acceptable_heads:
|
||||||
notes.append(
|
notes.append(
|
||||||
f"open PR #{only_number} head {only_sha or 'unknown'} does not "
|
f"open PR #{only_number} head {only_sha or 'unknown'} does not "
|
||||||
f"match the recovered head {token_head or 'unknown'} "
|
f"match the recovered head {token_head or 'unknown'}"
|
||||||
"(no owning-PR exemption)"
|
+ (
|
||||||
|
f" or the accepted head {token_accepted}"
|
||||||
|
if token_accepted and token_accepted != token_head
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
+ " (no owning-PR exemption)"
|
||||||
)
|
)
|
||||||
return False, notes
|
return False, notes
|
||||||
|
|
||||||
return True, [
|
return True, [
|
||||||
f"open PR #{only_number} is the exact PR already owned by the "
|
f"open PR #{only_number} is the exact PR already owned by the "
|
||||||
f"recovering lock for issue #{issue_number} (branch '{token_branch}', "
|
f"recovering lock for issue #{issue_number} (branch '{token_branch}', "
|
||||||
f"head {token_head}); not duplicate work"
|
f"head {only_sha}); not duplicate work"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -73,12 +73,21 @@ def owning_pr(number=OWNING_PR, ref=BRANCH, sha=HEAD, issue=ISSUE):
|
|||||||
def sanctioned_token(
|
def sanctioned_token(
|
||||||
issue_number=ISSUE, pr_number=OWNING_PR, branch=BRANCH, head=HEAD
|
issue_number=ISSUE, pr_number=OWNING_PR, branch=BRANCH, head=HEAD
|
||||||
):
|
):
|
||||||
"""The evidence shape the server derives from a granted recovery."""
|
"""The evidence shape the server derives from a granted recovery.
|
||||||
|
|
||||||
|
#768 extends the token with recorded/accepted heads and the head relation
|
||||||
|
so a strict-descendant recovery can still exempt the owning PR after the
|
||||||
|
remediation commit lands. Exact-head recovery (#753/#755) reports equal
|
||||||
|
heads under the same shape.
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
"issue_number": issue_number,
|
"issue_number": issue_number,
|
||||||
"pr_number": pr_number,
|
"pr_number": pr_number,
|
||||||
"branch_name": branch,
|
"branch_name": branch,
|
||||||
"head_sha": head,
|
"head_sha": head,
|
||||||
|
"recorded_head": head,
|
||||||
|
"accepted_head": head,
|
||||||
|
"head_relation": issue_lock_recovery.HEAD_RELATION_EQUAL,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,551 @@
|
|||||||
|
"""Strict-descendant dead-session recovery (#768).
|
||||||
|
|
||||||
|
After a dead author session, a preserved clean remediation commit that strictly
|
||||||
|
descends from the head recorded at lock time must be recoverable so the author
|
||||||
|
can publish. Equality alone is still accepted (#753); every other divergence
|
||||||
|
must keep failing closed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import issue_lock_recovery # noqa: E402
|
||||||
|
import issue_lock_store # noqa: E402
|
||||||
|
import issue_lock_worktree # noqa: E402
|
||||||
|
import issue_work_duplicate_gate # noqa: E402
|
||||||
|
|
||||||
|
ISSUE = 7680
|
||||||
|
PR_NUMBER = 7681
|
||||||
|
BRANCH = f"fix/issue-{ISSUE}-descendant-recovery"
|
||||||
|
WORKTREE = "/scratch/wt-768"
|
||||||
|
RECORDED = "a" * 40
|
||||||
|
DESCENDANT = "c" * 40
|
||||||
|
DIVERGED = "d" * 40
|
||||||
|
BEHIND = "b" * 40
|
||||||
|
IDENTITY = "example-user"
|
||||||
|
PROFILE = "example-author"
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_lock(**overrides):
|
||||||
|
lock = {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"worktree_path": WORKTREE,
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "ExampleOrg",
|
||||||
|
"repo": "ExampleRepo",
|
||||||
|
"session_pid": dead_pid(),
|
||||||
|
"work_lease": {
|
||||||
|
"operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch": BRANCH,
|
||||||
|
"worktree_path": WORKTREE,
|
||||||
|
"claimant": {"username": IDENTITY, "profile": PROFILE},
|
||||||
|
"expires_at": future_ts(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
lock.update(overrides)
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
def ancestry_ok(
|
||||||
|
*,
|
||||||
|
ancestor: str = RECORDED,
|
||||||
|
descendant: str = DESCENDANT,
|
||||||
|
is_strict: bool = True,
|
||||||
|
probe_ok: bool = True,
|
||||||
|
ancestor_present: bool = True,
|
||||||
|
reasons: list[str] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"ancestor_sha": ancestor,
|
||||||
|
"descendant_sha": descendant,
|
||||||
|
"probe_ok": probe_ok,
|
||||||
|
"ancestor_present": ancestor_present,
|
||||||
|
"descendant_present": True,
|
||||||
|
"is_ancestor": is_strict or ancestor == descendant,
|
||||||
|
"is_strict_descendant": is_strict,
|
||||||
|
"proof": f"git merge-base --is-ancestor {ancestor} {descendant} -> exit 0",
|
||||||
|
"reasons": list(reasons or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess(**overrides):
|
||||||
|
kwargs = {
|
||||||
|
"issue_number": ISSUE,
|
||||||
|
"branch_name": BRANCH,
|
||||||
|
"worktree_path": WORKTREE,
|
||||||
|
"remote": "prgs",
|
||||||
|
"org": "ExampleOrg",
|
||||||
|
"repo": "ExampleRepo",
|
||||||
|
"identity": IDENTITY,
|
||||||
|
"profile": PROFILE,
|
||||||
|
"current_branch": BRANCH,
|
||||||
|
"porcelain_status": "",
|
||||||
|
"head_sha": RECORDED,
|
||||||
|
"remote_head_sha": RECORDED,
|
||||||
|
"pr_head_sha": RECORDED,
|
||||||
|
"pr_number": PR_NUMBER,
|
||||||
|
"competing_live_locks": [],
|
||||||
|
"candidate_branches": [BRANCH],
|
||||||
|
"current_pid": os.getpid(),
|
||||||
|
"head_ancestry": None,
|
||||||
|
}
|
||||||
|
kwargs.update(overrides)
|
||||||
|
lock = kwargs.pop("lock", None)
|
||||||
|
return issue_lock_recovery.assess_dead_session_lock_recovery(
|
||||||
|
make_lock() if lock is None else lock, **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExactHeadRecoveryStillSucceeds(unittest.TestCase):
|
||||||
|
def test_equal_heads_still_sanctioned(self):
|
||||||
|
result = assess()
|
||||||
|
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
|
||||||
|
self.assertEqual(
|
||||||
|
result["evidence"]["head_relation"],
|
||||||
|
issue_lock_recovery.HEAD_RELATION_EQUAL,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["evidence"]["recorded_head"], RECORDED)
|
||||||
|
self.assertEqual(result["evidence"]["accepted_head"], RECORDED)
|
||||||
|
|
||||||
|
def test_exact_match_record_carries_relation(self):
|
||||||
|
record = issue_lock_recovery.build_recovery_record(
|
||||||
|
assess(), recovered_at="2026-07-20T00:00:00Z"
|
||||||
|
)
|
||||||
|
self.assertEqual(record["head_relation"], issue_lock_recovery.HEAD_RELATION_EQUAL)
|
||||||
|
self.assertEqual(record["recorded_head"], RECORDED)
|
||||||
|
self.assertEqual(record["accepted_head"], RECORDED)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStrictDescendantRecoverySucceeds(unittest.TestCase):
|
||||||
|
def test_clean_strict_descendant_recovers(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(),
|
||||||
|
)
|
||||||
|
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
|
||||||
|
self.assertEqual(
|
||||||
|
result["evidence"]["head_relation"],
|
||||||
|
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
|
||||||
|
)
|
||||||
|
self.assertEqual(result["evidence"]["recorded_head"], RECORDED)
|
||||||
|
self.assertEqual(result["evidence"]["accepted_head"], DESCENDANT)
|
||||||
|
self.assertIsNotNone(result["evidence"]["ancestry_proof"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("strictly descends" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pr_still_at_recorded_head_is_ok_for_descendant(self):
|
||||||
|
# Remediation is local only; open PR still points at the recorded head.
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(),
|
||||||
|
)
|
||||||
|
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
|
||||||
|
|
||||||
|
def test_recovery_record_names_both_heads_and_proof(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(),
|
||||||
|
)
|
||||||
|
record = issue_lock_recovery.build_recovery_record(
|
||||||
|
result, recovered_at="2026-07-20T00:00:00Z"
|
||||||
|
)
|
||||||
|
self.assertEqual(record["recorded_head"], RECORDED)
|
||||||
|
self.assertEqual(record["accepted_head"], DESCENDANT)
|
||||||
|
self.assertEqual(
|
||||||
|
record["head_relation"],
|
||||||
|
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
|
||||||
|
)
|
||||||
|
self.assertIn("strictly descends", record["ancestry_proof"] or "")
|
||||||
|
self.assertEqual(record["prior_session_pid"], result["evidence"]["prior_session_pid"])
|
||||||
|
self.assertEqual(record["replacement_session_pid"], os.getpid())
|
||||||
|
|
||||||
|
|
||||||
|
class TestDescendantEvidenceReachesPublicationGates(unittest.TestCase):
|
||||||
|
def _descendant_assessment(self):
|
||||||
|
return assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_owning_pr_evidence_carries_accepted_head(self):
|
||||||
|
token = issue_lock_recovery.owning_pr_recovery_evidence(
|
||||||
|
self._descendant_assessment()
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(token)
|
||||||
|
assert token is not None
|
||||||
|
self.assertEqual(token["head_sha"], RECORDED)
|
||||||
|
self.assertEqual(token["accepted_head"], DESCENDANT)
|
||||||
|
self.assertEqual(token["recorded_head"], RECORDED)
|
||||||
|
self.assertEqual(
|
||||||
|
token["head_relation"],
|
||||||
|
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_persisted_lock_rebuilds_owning_pr_evidence(self):
|
||||||
|
assessment = self._descendant_assessment()
|
||||||
|
record = issue_lock_recovery.build_recovery_record(
|
||||||
|
assessment, recovered_at="2026-07-20T00:00:00Z"
|
||||||
|
)
|
||||||
|
lock = make_lock(dead_session_recovery=record)
|
||||||
|
token = issue_lock_recovery.recovered_owning_pr_from_lock(lock)
|
||||||
|
self.assertIsNotNone(token)
|
||||||
|
assert token is not None
|
||||||
|
self.assertEqual(token["pr_number"], PR_NUMBER)
|
||||||
|
self.assertEqual(token["head_sha"], RECORDED)
|
||||||
|
self.assertEqual(token["accepted_head"], DESCENDANT)
|
||||||
|
|
||||||
|
def test_duplicate_gate_accepts_pr_at_recorded_or_accepted_head(self):
|
||||||
|
token = issue_lock_recovery.owning_pr_recovery_evidence(
|
||||||
|
self._descendant_assessment()
|
||||||
|
)
|
||||||
|
for live_sha in (RECORDED, DESCENDANT):
|
||||||
|
with self.subTest(live_sha=live_sha):
|
||||||
|
gate = issue_work_duplicate_gate.assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[
|
||||||
|
{
|
||||||
|
"number": PR_NUMBER,
|
||||||
|
"title": f"Closes #{ISSUE}",
|
||||||
|
"body": f"Closes #{ISSUE}",
|
||||||
|
"head": {"ref": BRANCH, "sha": live_sha},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "unclaimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=issue_work_duplicate_gate.PHASE_COMMIT,
|
||||||
|
recovered_owning_pr=token,
|
||||||
|
)
|
||||||
|
self.assertFalse(gate["block"], gate)
|
||||||
|
self.assertTrue(gate["owning_pr_recovery_exempted"])
|
||||||
|
|
||||||
|
def test_duplicate_gate_still_rejects_foreign_head(self):
|
||||||
|
token = issue_lock_recovery.owning_pr_recovery_evidence(
|
||||||
|
self._descendant_assessment()
|
||||||
|
)
|
||||||
|
gate = issue_work_duplicate_gate.assess_work_issue_duplicate_gate(
|
||||||
|
ISSUE,
|
||||||
|
open_prs=[
|
||||||
|
{
|
||||||
|
"number": PR_NUMBER,
|
||||||
|
"title": f"Closes #{ISSUE}",
|
||||||
|
"body": f"Closes #{ISSUE}",
|
||||||
|
"head": {"ref": BRANCH, "sha": DIVERGED},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
branch_names=[BRANCH],
|
||||||
|
claim_entry={"status": "unclaimed"},
|
||||||
|
locked_branch=BRANCH,
|
||||||
|
phase=issue_work_duplicate_gate.PHASE_COMMIT,
|
||||||
|
recovered_owning_pr=token,
|
||||||
|
)
|
||||||
|
self.assertTrue(gate["block"])
|
||||||
|
self.assertFalse(gate["owning_pr_recovery_exempted"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestDirtyDescendantRejected(unittest.TestCase):
|
||||||
|
def test_dirty_descendant_refused(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(),
|
||||||
|
porcelain_status=" M issue_lock_recovery.py\n",
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertTrue(any("dirty" in r.lower() for r in result["reasons"]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDivergedAndBehindRejected(unittest.TestCase):
|
||||||
|
def test_diverged_head_refused(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DIVERGED,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(
|
||||||
|
ancestor=RECORDED,
|
||||||
|
descendant=DIVERGED,
|
||||||
|
is_strict=False,
|
||||||
|
reasons=[
|
||||||
|
f"local head {DIVERGED} does not descend from recorded head "
|
||||||
|
f"{RECORDED}"
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertIsNone(result["evidence"].get("head_relation"))
|
||||||
|
self.assertTrue(
|
||||||
|
any("does not match remote" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_local_behind_recorded_refused(self):
|
||||||
|
# merge-base --is-ancestor RECORDED BEHIND is false when BEHIND is ancestor.
|
||||||
|
result = assess(
|
||||||
|
head_sha=BEHIND,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(
|
||||||
|
ancestor=RECORDED,
|
||||||
|
descendant=BEHIND,
|
||||||
|
is_strict=False,
|
||||||
|
reasons=[
|
||||||
|
f"local head {BEHIND} does not descend from recorded head "
|
||||||
|
f"{RECORDED}"
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("does not match remote" in r or "not a strict descendant" in r
|
||||||
|
for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnrelatedAndMalformedAncestryRejected(unittest.TestCase):
|
||||||
|
def test_missing_ancestry_observation_fails_closed(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=None,
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("ancestry" in r.lower() for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mismatched_probe_pair_fails_closed(self):
|
||||||
|
# Observation for a different commit pair must not authorize this pair.
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(ancestor=DIVERGED, descendant=DESCENDANT),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("not the heads under assessment" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rewritten_recorded_head_fails_closed(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(ancestor_present=False, is_strict=False),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("no longer reachable" in r or "rewritten" in r
|
||||||
|
for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_failed_probe_fails_closed(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(
|
||||||
|
probe_ok=False,
|
||||||
|
is_strict=False,
|
||||||
|
reasons=["ancestry probe failed with exit 128; ancestry unproven"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
|
||||||
|
def test_pr_head_not_equal_to_recorded_blocks_descendant(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=DIVERGED,
|
||||||
|
head_ancestry=ancestry_ok(),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("open PR" in r and "does not match" in r for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLiveOwnerStillRejected(unittest.TestCase):
|
||||||
|
def test_live_prior_pid_refused_even_with_descendant_proof(self):
|
||||||
|
result = assess(
|
||||||
|
lock=make_lock(session_pid=os.getpid()),
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(),
|
||||||
|
)
|
||||||
|
self.assertFalse(result["recovery_sanctioned"])
|
||||||
|
self.assertTrue(
|
||||||
|
any("still alive" in r or "live" in r.lower() for r in result["reasons"]),
|
||||||
|
result["reasons"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiagnosticsIdentifyDisposition(unittest.TestCase):
|
||||||
|
def test_equal_disposition_named(self):
|
||||||
|
result = assess()
|
||||||
|
self.assertEqual(
|
||||||
|
result["evidence"]["head_relation"],
|
||||||
|
issue_lock_recovery.HEAD_RELATION_EQUAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_descendant_disposition_named(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DESCENDANT,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
pr_head_sha=RECORDED,
|
||||||
|
head_ancestry=ancestry_ok(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result["evidence"]["head_relation"],
|
||||||
|
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejected_divergence_has_no_accepted_relation(self):
|
||||||
|
result = assess(
|
||||||
|
head_sha=DIVERGED,
|
||||||
|
remote_head_sha=RECORDED,
|
||||||
|
head_ancestry=None,
|
||||||
|
)
|
||||||
|
self.assertIsNone(result["evidence"].get("head_relation"))
|
||||||
|
message = issue_lock_recovery.format_recovery_refusal(result)
|
||||||
|
self.assertIn("fail closed", message)
|
||||||
|
self.assertIn("does not match remote", message)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadHeadAncestryRealGit(unittest.TestCase):
|
||||||
|
def _git(self, repo: str, *args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "-C", repo, *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _init_repo_with_chain(self) -> tuple[str, str, str, str]:
|
||||||
|
"""Return (repo, parent_sha, child_sha, sibling_sha)."""
|
||||||
|
repo = tempfile.mkdtemp(prefix="issue-768-ancestry-")
|
||||||
|
self._git(repo, "init")
|
||||||
|
self._git(repo, "config", "user.email", "[email protected]")
|
||||||
|
self._git(repo, "config", "user.name", "Test")
|
||||||
|
path = Path(repo) / "f.txt"
|
||||||
|
path.write_text("one\n")
|
||||||
|
self._git(repo, "add", "f.txt")
|
||||||
|
self._git(repo, "commit", "-m", "parent")
|
||||||
|
parent = self._git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||||
|
path.write_text("two\n")
|
||||||
|
self._git(repo, "add", "f.txt")
|
||||||
|
self._git(repo, "commit", "-m", "child")
|
||||||
|
child = self._git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||||
|
# Divergent sibling: branch from parent, then unique commit.
|
||||||
|
self._git(repo, "checkout", "-B", "side", parent)
|
||||||
|
path.write_text("side\n")
|
||||||
|
self._git(repo, "add", "f.txt")
|
||||||
|
self._git(repo, "commit", "-m", "sibling")
|
||||||
|
sibling = self._git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||||
|
self._git(repo, "checkout", "-B", "main", child)
|
||||||
|
return repo, parent, child, sibling
|
||||||
|
|
||||||
|
def test_strict_descendant_observation(self):
|
||||||
|
repo, parent, child, _sibling = self._init_repo_with_chain()
|
||||||
|
obs = issue_lock_worktree.read_head_ancestry(
|
||||||
|
repo, ancestor_sha=parent, descendant_sha=child
|
||||||
|
)
|
||||||
|
self.assertTrue(obs["probe_ok"])
|
||||||
|
self.assertTrue(obs["ancestor_present"])
|
||||||
|
self.assertTrue(obs["is_ancestor"])
|
||||||
|
self.assertTrue(obs["is_strict_descendant"])
|
||||||
|
self.assertEqual(obs["ancestor_sha"], parent)
|
||||||
|
self.assertEqual(obs["descendant_sha"], child)
|
||||||
|
|
||||||
|
def test_equal_heads_not_strict_descendant(self):
|
||||||
|
repo, parent, _child, _sibling = self._init_repo_with_chain()
|
||||||
|
obs = issue_lock_worktree.read_head_ancestry(
|
||||||
|
repo, ancestor_sha=parent, descendant_sha=parent
|
||||||
|
)
|
||||||
|
self.assertTrue(obs["probe_ok"])
|
||||||
|
self.assertTrue(obs["is_ancestor"])
|
||||||
|
self.assertFalse(obs["is_strict_descendant"])
|
||||||
|
|
||||||
|
def test_diverged_not_ancestor(self):
|
||||||
|
repo, _parent, child, sibling = self._init_repo_with_chain()
|
||||||
|
# child and sibling share a parent but neither descends from the other.
|
||||||
|
obs = issue_lock_worktree.read_head_ancestry(
|
||||||
|
repo, ancestor_sha=child, descendant_sha=sibling
|
||||||
|
)
|
||||||
|
self.assertTrue(obs["probe_ok"])
|
||||||
|
self.assertFalse(obs["is_ancestor"])
|
||||||
|
self.assertFalse(obs["is_strict_descendant"])
|
||||||
|
|
||||||
|
def test_missing_sha_fails_closed(self):
|
||||||
|
repo, _parent, child, _ = self._init_repo_with_chain()
|
||||||
|
obs = issue_lock_worktree.read_head_ancestry(
|
||||||
|
repo, ancestor_sha="0" * 40, descendant_sha=child
|
||||||
|
)
|
||||||
|
self.assertFalse(obs["probe_ok"])
|
||||||
|
self.assertFalse(obs["ancestor_present"])
|
||||||
|
|
||||||
|
def test_end_to_end_real_git_descendant_recovery(self):
|
||||||
|
repo, parent, child, _sibling = self._init_repo_with_chain()
|
||||||
|
obs = issue_lock_worktree.read_head_ancestry(
|
||||||
|
repo, ancestor_sha=parent, descendant_sha=child
|
||||||
|
)
|
||||||
|
result = assess(
|
||||||
|
worktree_path=repo,
|
||||||
|
head_sha=child,
|
||||||
|
remote_head_sha=parent,
|
||||||
|
pr_head_sha=parent,
|
||||||
|
head_ancestry=obs,
|
||||||
|
lock=make_lock(worktree_path=repo),
|
||||||
|
)
|
||||||
|
self.assertTrue(result["recovery_sanctioned"], result["reasons"])
|
||||||
|
self.assertEqual(
|
||||||
|
result["evidence"]["head_relation"],
|
||||||
|
issue_lock_recovery.HEAD_RELATION_STRICT_DESCENDANT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user