A durable author issue lock records the PID of the MCP session that took it.
When that process exits, assess_lock_freshness marks the lock stale (live=False)
even while its lease is still within TTL, so every ownership check that needs a
live lock fails closed -- including gitea_update_pr_branch_by_merge.
Re-taking the lock was unreachable for real work. assess_issue_lock_worktree
requires the worktree to be base-equivalent to master/main/dev, and a branch
that already carries commits is ahead of its base by construction. The existing
assess_expired_lock_reclaim affordance does not apply either: it is only
consulted once the lease has expired, so a dead PID under an unexpired lease
never reaches it. assess_own_branch_adoption already speaks of "lock recovery",
but it runs after the base-equivalence gate and so was never reached.
This adds issue_lock_recovery, a pure assessor that grants a narrow waiver only
when every element of durable ownership still matches exactly and the recorded
process is demonstrably dead: same remote/org/repo/issue, same branch (and the
worktree actually on it), same registered worktree, clean worktree, local head
== remote head == open PR head, same claimant identity/profile, no competing
live lock or lease, and no ambiguous branch claims. A malformed or incomplete
lock record can never prove ownership.
The waiver suppresses base-equivalence and nothing else. Cleanliness and every
other precondition still apply, and brand-new issue claims keep the full
requirement. A refused assessment never raises: it withholds the waiver and
lets the pre-existing guard fail closed exactly as before, so recovery can only
ever add permission, never remove a guard. Refusal reasons are appended to the
block message so a caller sees the exact missing evidence.
A completed recovery is recorded on the lock as dead_session_recovery with the
prior and replacement session PIDs, heads, and claimant, so the takeover is
auditable and never looks like an original claim. Rebinding sets the live
session PID, so the recovered lock satisfies verify_lock_for_mutation and the
downstream PR update paths.
Validation:
* new tests/test_issue_753_dead_pid_lock_recovery.py -- 33 passed
* issue-lock, adoption, store, provenance, registration, duplicate-gate,
worktree, create-issue-guard suites -- 120 passed
* MCP server, commit payloads, handoff ledger, PR ownership, branch cleanup
suites -- 286 passed
* full suite -- 3498 passed, 6 skipped, 2 failed
* the same 2 failures reproduce identically on pristine master 0425bf9a
(test_issue_702_review_findings_f1_f6 F1 recovery-before-probe and
test_reconciler_supersession_close org/repo forwarding), so they are
pre-existing and unrelated
* git diff --check clean
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01L9jMhtvTjm5EajofqqaR3F
401 lines
15 KiB
Python
401 lines
15 KiB
Python
"""Dead-session author issue-lock recovery (#753).
|
|
|
|
A durable author issue lock records the PID of the MCP session that took it.
|
|
When that process exits, ``issue_lock_store.assess_lock_freshness`` classifies
|
|
the lock as ``stale`` (``live=False``) even while its lease is still within TTL,
|
|
so every ownership check that requires a *live* lock fails closed.
|
|
|
|
Re-taking the lock through ``gitea_lock_issue`` is unreachable for real work:
|
|
``issue_lock_worktree.assess_issue_lock_worktree`` demands the worktree be
|
|
base-equivalent to ``master``/``main``/``dev``, and a branch that already
|
|
carries commits is ahead of its base by construction. The existing
|
|
``assess_expired_lock_reclaim`` affordance does not apply either, because
|
|
``assess_same_issue_lease_conflict`` only consults it once the lease has
|
|
*expired* — a dead PID under an unexpired lease never reaches it.
|
|
|
|
This module is the pure evidence assessor for that one narrow case. It grants
|
|
recovery only when every element of durable ownership still matches exactly and
|
|
the recorded process is demonstrably dead. It never trusts caller assertions:
|
|
every field is compared against durable lock state or live observation supplied
|
|
by the caller. It performs no mutation and no network I/O.
|
|
|
|
Recovery deliberately does **not** relax base-equivalence for brand-new issue
|
|
claims — only for a lock whose own prior record already proves the branch,
|
|
worktree, head, and author.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Any, Iterable, Mapping, Sequence
|
|
|
|
from issue_lock_store import is_process_alive
|
|
from reviewer_worktree import parse_dirty_tracked_files
|
|
|
|
# Outcome values
|
|
RECOVERY_SANCTIONED = "RECOVERY_SANCTIONED"
|
|
NO_CANDIDATE = "NO_CANDIDATE"
|
|
REFUSED = "REFUSED"
|
|
|
|
# Durable fields a lock must carry before it can be considered at all.
|
|
REQUIRED_LOCK_FIELDS = ("issue_number", "branch_name", "worktree_path")
|
|
|
|
|
|
def _same_realpath(left: str | None, right: str | None) -> bool:
|
|
if not left or not right:
|
|
return False
|
|
try:
|
|
return os.path.realpath(left) == os.path.realpath(right)
|
|
except OSError:
|
|
return left == right
|
|
|
|
|
|
def _text(value: Any) -> str:
|
|
return str(value or "").strip()
|
|
|
|
|
|
def _lock_claimant(lock: Mapping[str, Any]) -> dict[str, Any]:
|
|
claimant = lock.get("claimant")
|
|
if not isinstance(claimant, Mapping):
|
|
lease = lock.get("work_lease")
|
|
claimant = lease.get("claimant") if isinstance(lease, Mapping) else None
|
|
return dict(claimant) if isinstance(claimant, Mapping) else {}
|
|
|
|
|
|
def _recorded_pid(lock: Mapping[str, Any]) -> Any:
|
|
pid = lock.get("session_pid")
|
|
if pid is None:
|
|
pid = lock.get("pid")
|
|
return pid
|
|
|
|
|
|
def _malformed_reasons(lock: Mapping[str, Any]) -> list[str]:
|
|
"""Names of durable fields that are missing or unusable."""
|
|
missing: list[str] = []
|
|
for field in REQUIRED_LOCK_FIELDS:
|
|
if not _text(lock.get(field)):
|
|
missing.append(field)
|
|
pid = _recorded_pid(lock)
|
|
if pid is None or _text(pid) == "":
|
|
missing.append("session_pid/pid")
|
|
else:
|
|
try:
|
|
if int(pid) <= 0:
|
|
missing.append("session_pid/pid")
|
|
except (TypeError, ValueError):
|
|
missing.append("session_pid/pid")
|
|
return missing
|
|
|
|
|
|
def assess_dead_session_lock_recovery(
|
|
existing_lock: Mapping[str, Any] | None,
|
|
*,
|
|
issue_number: int,
|
|
branch_name: str,
|
|
worktree_path: str,
|
|
remote: str,
|
|
org: str,
|
|
repo: str,
|
|
identity: str | None,
|
|
profile: str | None,
|
|
current_branch: str | None,
|
|
porcelain_status: str,
|
|
head_sha: str | None,
|
|
remote_head_sha: str | None,
|
|
pr_head_sha: str | None = None,
|
|
pr_number: int | None = None,
|
|
competing_live_locks: Sequence[Mapping[str, Any]] | None = None,
|
|
candidate_branches: Iterable[str] | None = None,
|
|
current_pid: int | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Decide whether a dead-session author lock may be natively recovered.
|
|
|
|
Returns a dict with ``recovery_sanctioned`` (bool), ``outcome``, ``reasons``
|
|
(why it was refused, or the positive proof when sanctioned), and
|
|
``evidence`` (a redaction-safe record for auditing).
|
|
|
|
``NO_CANDIDATE`` means no recovery was attempted at all — there is no
|
|
existing lock, or the lock does not describe this issue. The caller must
|
|
treat that exactly as it treated the pre-#753 world. ``REFUSED`` means a
|
|
candidate existed but the evidence did not agree; the caller fails closed.
|
|
"""
|
|
reasons: list[str] = []
|
|
evidence: dict[str, Any] = {
|
|
"issue_number": issue_number,
|
|
"branch_name": branch_name,
|
|
"worktree_path": worktree_path,
|
|
"remote": remote,
|
|
"org": org,
|
|
"repo": repo,
|
|
}
|
|
|
|
if not existing_lock:
|
|
return _result(
|
|
NO_CANDIDATE, False, ["no existing durable lock for this issue"], evidence
|
|
)
|
|
|
|
lock = dict(existing_lock)
|
|
|
|
# ── Candidate identification ────────────────────────────────────────────
|
|
# Recovery only ever applies to a lock that already claims THIS issue.
|
|
# Anything else is not a recovery candidate and must not be reinterpreted.
|
|
if lock.get("issue_number") != issue_number:
|
|
return _result(
|
|
NO_CANDIDATE,
|
|
False,
|
|
[
|
|
f"existing lock targets issue #{lock.get('issue_number')}, "
|
|
f"not #{issue_number}; not a recovery candidate"
|
|
],
|
|
evidence,
|
|
)
|
|
|
|
# A malformed/incomplete durable record can never prove ownership.
|
|
missing = _malformed_reasons(lock)
|
|
if missing:
|
|
return _result(
|
|
REFUSED,
|
|
False,
|
|
[
|
|
"durable lock record is incomplete and cannot prove ownership "
|
|
f"(missing/unusable: {', '.join(missing)})"
|
|
],
|
|
evidence,
|
|
)
|
|
|
|
recorded_pid = _recorded_pid(lock)
|
|
evidence["prior_session_pid"] = recorded_pid
|
|
evidence["replacement_session_pid"] = (
|
|
current_pid if current_pid is not None else os.getpid()
|
|
)
|
|
|
|
# ── Repository scope ────────────────────────────────────────────────────
|
|
for field, expected in (("remote", remote), ("org", org), ("repo", repo)):
|
|
actual = _text(lock.get(field))
|
|
if actual != _text(expected):
|
|
reasons.append(
|
|
f"lock {field} '{actual}' does not match requested '{_text(expected)}'"
|
|
)
|
|
|
|
# ── Branch identity ─────────────────────────────────────────────────────
|
|
locked_branch = _text(lock.get("branch_name"))
|
|
if locked_branch != _text(branch_name):
|
|
reasons.append(
|
|
f"lock branch '{locked_branch}' does not match requested "
|
|
f"'{_text(branch_name)}'"
|
|
)
|
|
evidence["locked_branch"] = locked_branch
|
|
|
|
# The worktree must actually be sitting on the locked branch. Without this
|
|
# a clean worktree parked elsewhere could stand in for the real work.
|
|
checked_out = _text(current_branch)
|
|
if not checked_out:
|
|
reasons.append(
|
|
"worktree is not on a named branch (detached HEAD); locked-branch "
|
|
"occupancy could not be proven"
|
|
)
|
|
elif checked_out != locked_branch:
|
|
reasons.append(
|
|
f"worktree is on branch '{checked_out}', not the locked branch "
|
|
f"'{locked_branch}'"
|
|
)
|
|
|
|
# ── Worktree identity ───────────────────────────────────────────────────
|
|
locked_worktree = _text(lock.get("worktree_path"))
|
|
if not _same_realpath(locked_worktree, worktree_path):
|
|
reasons.append(
|
|
f"lock worktree '{locked_worktree}' does not match declared "
|
|
f"'{_text(worktree_path)}'"
|
|
)
|
|
evidence["locked_worktree_path"] = locked_worktree
|
|
|
|
# ── Cleanliness (never waived) ──────────────────────────────────────────
|
|
dirty_files = parse_dirty_tracked_files(porcelain_status)
|
|
if dirty_files:
|
|
reasons.append(
|
|
"worktree has tracked local edits; recovery requires a clean "
|
|
f"worktree (dirty files: {', '.join(dirty_files)})"
|
|
)
|
|
evidence["dirty_files"] = dirty_files
|
|
|
|
# ── Head agreement: local == remote == PR ───────────────────────────────
|
|
local_head = _text(head_sha)
|
|
remote_head = _text(remote_head_sha)
|
|
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 and local_head != remote_head:
|
|
reasons.append(
|
|
f"local head {local_head} does not match remote branch head {remote_head}"
|
|
)
|
|
evidence["local_head"] = local_head or None
|
|
evidence["remote_head"] = remote_head or None
|
|
|
|
pr_head = _text(pr_head_sha)
|
|
if pr_head:
|
|
evidence["pr_head"] = pr_head
|
|
evidence["pr_number"] = pr_number
|
|
if local_head and pr_head != local_head:
|
|
reasons.append(
|
|
f"open PR #{pr_number} head {pr_head} does not match local head "
|
|
f"{local_head}"
|
|
)
|
|
|
|
# ── Author identity ─────────────────────────────────────────────────────
|
|
claimant = _lock_claimant(lock)
|
|
locked_identity = _text(claimant.get("username"))
|
|
locked_profile = _text(claimant.get("profile"))
|
|
evidence["locked_identity"] = locked_identity or None
|
|
evidence["locked_profile"] = locked_profile or None
|
|
if not locked_identity or not locked_profile:
|
|
reasons.append(
|
|
"durable lock does not record a claimant identity/profile; "
|
|
"author ownership could not be proven"
|
|
)
|
|
if not _text(identity) or not _text(profile):
|
|
reasons.append(
|
|
"active session identity/profile is unknown; author ownership "
|
|
"could not be proven"
|
|
)
|
|
if locked_identity and _text(identity) and locked_identity != _text(identity):
|
|
reasons.append(
|
|
f"lock claimant '{locked_identity}' does not match active identity "
|
|
f"'{_text(identity)}'"
|
|
)
|
|
if locked_profile and _text(profile) and locked_profile != _text(profile):
|
|
reasons.append(
|
|
f"lock profile '{locked_profile}' does not match active profile "
|
|
f"'{_text(profile)}'"
|
|
)
|
|
|
|
# ── The defining condition: the recorded owner must be dead ─────────────
|
|
prior_alive = is_process_alive(recorded_pid)
|
|
evidence["prior_pid_alive"] = prior_alive
|
|
if prior_alive:
|
|
reasons.append(
|
|
f"prior owner pid {recorded_pid} is still alive; this is not a "
|
|
"dead-session recovery"
|
|
)
|
|
if current_pid is not None and recorded_pid is not None:
|
|
try:
|
|
if int(recorded_pid) == int(current_pid):
|
|
reasons.append(
|
|
"recorded pid is the current session; nothing to recover"
|
|
)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
# ── Competing ownership ─────────────────────────────────────────────────
|
|
competing: list[dict[str, Any]] = []
|
|
for entry in competing_live_locks or ():
|
|
if not isinstance(entry, Mapping):
|
|
continue
|
|
same_issue = entry.get("issue_number") == issue_number
|
|
same_branch = _text(entry.get("branch_name")) == locked_branch
|
|
if not (same_issue or same_branch):
|
|
continue
|
|
# The lock we are recovering is not competition with itself.
|
|
if (
|
|
same_issue
|
|
and same_branch
|
|
and _same_realpath(_text(entry.get("worktree_path")), worktree_path)
|
|
):
|
|
continue
|
|
competing.append(
|
|
{
|
|
"issue_number": entry.get("issue_number"),
|
|
"branch_name": entry.get("branch_name"),
|
|
"worktree_path": entry.get("worktree_path"),
|
|
"pid": entry.get("pid"),
|
|
}
|
|
)
|
|
if competing:
|
|
described = ", ".join(
|
|
f"issue #{c['issue_number']} branch '{c['branch_name']}'" for c in competing
|
|
)
|
|
reasons.append(f"competing live lock or lease exists ({described})")
|
|
evidence["competing_live_locks"] = competing
|
|
|
|
# ── Ambiguous branch claims ─────────────────────────────────────────────
|
|
others = [
|
|
name
|
|
for name in (candidate_branches or ())
|
|
if _text(name) and _text(name) != locked_branch
|
|
]
|
|
if others:
|
|
reasons.append(
|
|
"multiple branches claim this issue "
|
|
f"({', '.join(sorted(set(others)))}); ownership is ambiguous"
|
|
)
|
|
evidence["other_candidate_branches"] = sorted(set(others))
|
|
|
|
if reasons:
|
|
return _result(REFUSED, False, reasons, evidence)
|
|
|
|
return _result(
|
|
RECOVERY_SANCTIONED,
|
|
True,
|
|
[
|
|
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"
|
|
],
|
|
evidence,
|
|
)
|
|
|
|
|
|
def _result(
|
|
outcome: str,
|
|
sanctioned: bool,
|
|
reasons: list[str],
|
|
evidence: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"outcome": outcome,
|
|
"recovery_sanctioned": sanctioned,
|
|
"is_candidate": outcome != NO_CANDIDATE,
|
|
"reasons": reasons,
|
|
"evidence": evidence,
|
|
}
|
|
|
|
|
|
def build_recovery_record(
|
|
assessment: Mapping[str, Any],
|
|
*,
|
|
recovered_at: str,
|
|
) -> dict[str, Any]:
|
|
"""Durable, secret-free provenance for a completed recovery (#753 AC2/AC6)."""
|
|
evidence = dict(assessment.get("evidence") or {})
|
|
return {
|
|
"recovered": True,
|
|
"reason": "owning MCP session exited; durable ownership evidence matched",
|
|
"recovered_at": recovered_at,
|
|
"prior_session_pid": evidence.get("prior_session_pid"),
|
|
"replacement_session_pid": evidence.get("replacement_session_pid"),
|
|
"prior_pid_alive": evidence.get("prior_pid_alive"),
|
|
"branch_name": evidence.get("locked_branch"),
|
|
"worktree_path": evidence.get("locked_worktree_path"),
|
|
"local_head": evidence.get("local_head"),
|
|
"remote_head": evidence.get("remote_head"),
|
|
"pr_head": evidence.get("pr_head"),
|
|
"pr_number": evidence.get("pr_number"),
|
|
"identity": evidence.get("locked_identity"),
|
|
"profile": evidence.get("locked_profile"),
|
|
"proof": list(assessment.get("reasons") or []),
|
|
}
|
|
|
|
|
|
def format_recovery_refusal(assessment: Mapping[str, Any]) -> str:
|
|
"""Single fail-closed message for a refused recovery attempt."""
|
|
reasons = list(assessment.get("reasons") or []) or [
|
|
"dead-session lock recovery evidence did not agree"
|
|
]
|
|
return (
|
|
"Dead-session issue-lock recovery refused: "
|
|
+ "; ".join(reasons)
|
|
+ " (fail closed)"
|
|
)
|