"""Exact-owner renewal of an expired author issue lease (#760). An author issue lease carries an absolute wall-clock expiry stamped once at lock time. The PID recorded alongside it is the long-lived MCP daemon, not the authoring task, so a lease that expires while its daemon is still up is the ordinary case for any author task that outlives the TTL — not an anomaly. Before this module, that case was unreachable. ``issue_lock_store.assess_same_issue_lease_conflict`` computed same-owner evidence and then returned on the expired branch before consulting it, and ``assess_expired_lock_reclaim`` only permits takeover on a dead PID or a missing worktree. An exact owner whose daemon is alive and whose worktree is present satisfied neither, so its own lock became permanently unmodifiable through sanctioned tools. This module is the pure evidence assessor for that one narrow case. It answers a single question: may *this* session renew a lease it can prove it already owns? It performs no mutation and no network I/O, and it never trusts a caller assertion — every field is compared against durable lock state or a live observation supplied by the caller and gathered server-side. Deliberate boundaries: * **Renewal is not takeover.** A refusal here never widens what ``assess_expired_lock_reclaim`` already allows; foreign expired locks keep requiring a dead PID or missing worktree (#760 AC11), and a *live* foreign lease stays non-recoverable by construction because only an expired lease is ever a candidate (AC12). * **PID liveness is never authorization.** A live recorded PID proves the daemon is up, nothing more. It is recorded as evidence and is neither necessary nor sufficient for renewal (AC16). * **Absolute expiry is preserved.** Renewal issues a new absolute expiry from the moment of the write. It does not introduce sliding heartbeat renewal, lease generations as fencing tokens, or a shared cross-role lifecycle — that is #790's scope and is deliberately not implemented here. """ from __future__ import annotations import os from typing import Any, Iterable, Mapping, Sequence from issue_lock_store import AUTHOR_ISSUE_WORK_LEASE, is_lease_expired, is_process_alive from reviewer_worktree import parse_dirty_tracked_files # Outcome values. RENEWAL_SANCTIONED = "RENEWAL_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 _text(value: Any) -> str: return str(value or "").strip() 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 _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 _lock_lease(lock: Mapping[str, Any]) -> dict[str, Any]: lease = lock.get("work_lease") return dict(lease) if isinstance(lease, Mapping) else {} def _lock_operation_type(lock: Mapping[str, Any]) -> str: lease = _lock_lease(lock) return _text(lease.get("operation_type")) or AUTHOR_ISSUE_WORK_LEASE 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 _competing_lock_reasons( competing_live_locks: Iterable[Mapping[str, Any]] | None, *, issue_number: int, branch_name: str, worktree_path: str, ) -> list[str]: """Live locks that would contend with this renewal (#760 AC7). A live lock on the *same* issue cannot coexist with this expired lease, so any live entry naming this issue, branch, or worktree belongs to somebody else and refuses the renewal. """ reasons: list[str] = [] for entry in competing_live_locks or (): if not isinstance(entry, Mapping): continue entry_issue = entry.get("issue_number") entry_branch = _text(entry.get("branch_name")) entry_worktree = _text(entry.get("worktree_path")) if entry_issue == issue_number: reasons.append( f"a live lock already exists for issue #{issue_number} " f"(pid {entry.get('pid')}); renewal would contend with it" ) continue if entry_branch and entry_branch == _text(branch_name): reasons.append( f"live lock for issue #{entry_issue} already holds branch " f"'{branch_name}'" ) if entry_worktree and _same_realpath(entry_worktree, worktree_path): reasons.append( f"live lock for issue #{entry_issue} already holds worktree " f"'{worktree_path}'" ) return reasons def assess_exact_owner_lease_renewal( 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, operation_type: str = AUTHOR_ISSUE_WORK_LEASE, current_branch: str | None = None, porcelain_status: str = "", worktree_exists: bool = False, head_sha: str | None = None, remote_head_sha: str | None = None, pr_head_sha: str | None = None, pr_number: int | None = None, competing_live_locks: Sequence[Mapping[str, Any]] | None = None, candidate_branches: Sequence[str] | None = None, current_pid: int | None = None, now: Any = None, ) -> dict[str, Any]: """Decide whether an expired lease may be renewed by its exact owner. Returns a disposition dict; it never raises and never mutates. A refusal withholds permission, leaving every pre-existing guard to fail closed exactly as before — this assessment can only ever *add* permission. ``NO_CANDIDATE`` means the situation is not an exact-owner renewal at all (no lock, different issue, different operation, or an unexpired lease) and the caller should carry on with its normal path. ``REFUSED`` means it looked like one but the evidence did not hold, and ``reasons`` names exactly what was missing. """ evidence: dict[str, Any] = { "issue_number": issue_number, "branch_name": branch_name, "worktree_path": worktree_path, "remote": remote, "org": org, "repo": repo, "operation_type": operation_type, "identity": identity, "profile": profile, } def _result(outcome: str, reasons: list[str], **extra: Any) -> dict[str, Any]: return { "outcome": outcome, "renewal_sanctioned": outcome == RENEWAL_SANCTIONED, "is_candidate": outcome in (RENEWAL_SANCTIONED, REFUSED), "reasons": reasons, "evidence": {**evidence, **extra}, } if not isinstance(existing_lock, Mapping) or not existing_lock: return _result(NO_CANDIDATE, ["no existing lock to renew"]) if existing_lock.get("issue_number") != issue_number: return _result( NO_CANDIDATE, [ f"existing lock is for issue #{existing_lock.get('issue_number')}, " f"not #{issue_number}" ], ) existing_operation = _lock_operation_type(existing_lock) if existing_operation != operation_type: return _result( NO_CANDIDATE, [ f"existing lease operation '{existing_operation}' is not " f"'{operation_type}'" ], ) # Only an *expired* lease is ever a renewal candidate. An unexpired lease — # live, or stale by dead PID — is somebody else's problem: the first needs no # renewal, and the second is #753's dead-session recovery. This is also what # makes a live foreign lease non-recoverable here (#760 AC12). if not is_lease_expired(existing_lock, now=now): return _result( NO_CANDIDATE, ["lease has not expired; renewal does not apply"], ) malformed = _malformed_reasons(existing_lock) if malformed: return _result( REFUSED, ["durable lock is missing or has unusable fields: " + ", ".join(malformed)], ) lease = _lock_lease(existing_lock) claimant = _lock_claimant(existing_lock) recorded_pid = _recorded_pid(existing_lock) prior_expires_at = _text(lease.get("expires_at")) # #760 AC16: recorded purely as evidence. A live daemon PID is neither # necessary nor sufficient for renewal, and nothing below branches on it. recorded_pid_alive = is_process_alive(recorded_pid) extra: dict[str, Any] = { "prior_pid": recorded_pid, "prior_pid_alive": recorded_pid_alive, "prior_expires_at": prior_expires_at, "replacement_pid": current_pid, "recorded_claimant": claimant, "head_sha": head_sha, "remote_head_sha": remote_head_sha, "pr_head_sha": pr_head_sha, "pr_number": pr_number, } reasons: list[str] = [] # ── AC3: exact ownership identity ── if _text(existing_lock.get("remote")) != _text(remote): reasons.append( f"recorded remote '{existing_lock.get('remote')}' does not match " f"'{remote}'" ) if _text(existing_lock.get("org")) != _text(org): reasons.append( f"recorded org '{existing_lock.get('org')}' does not match '{org}'" ) if _text(existing_lock.get("repo")) != _text(repo): reasons.append( f"recorded repo '{existing_lock.get('repo')}' does not match '{repo}'" ) if _text(existing_lock.get("branch_name")) != _text(branch_name): reasons.append( f"recorded branch '{existing_lock.get('branch_name')}' does not match " f"'{branch_name}'" ) if not _same_realpath(_text(existing_lock.get("worktree_path")), worktree_path): reasons.append( f"recorded worktree '{existing_lock.get('worktree_path')}' does not " f"match '{worktree_path}'" ) recorded_identity = _text(claimant.get("username")) recorded_profile = _text(claimant.get("profile")) if not recorded_identity or not recorded_profile: reasons.append( "durable lock does not record both a claimant username and profile" ) if recorded_identity and recorded_identity != _text(identity): reasons.append( f"recorded claimant '{recorded_identity}' does not match active " f"identity '{_text(identity) or 'unknown'}'" ) if recorded_profile and recorded_profile != _text(profile): reasons.append( f"recorded profile '{recorded_profile}' does not match active profile " f"'{_text(profile) or 'unknown'}'" ) # ── AC4: the registered worktree still exists, is on the branch, and is clean ── if not worktree_exists: reasons.append(f"declared worktree '{worktree_path}' does not exist") if _text(current_branch) != _text(branch_name): reasons.append( f"worktree is on branch '{_text(current_branch) or 'unknown'}', not " f"'{branch_name}'" ) dirty = parse_dirty_tracked_files(porcelain_status or "") if dirty: reasons.append( "worktree has uncommitted tracked changes: " + ", ".join(sorted(dirty)) ) # ── AC5/AC6: published heads must agree ── if not _text(head_sha): reasons.append("local head could not be observed") if not _text(remote_head_sha): reasons.append( "remote branch head could not be observed; an unpublished branch " "cannot prove exact-owner renewal" ) if _text(head_sha) and _text(remote_head_sha) and head_sha != remote_head_sha: reasons.append( f"local head {head_sha} does not equal remote head {remote_head_sha}" ) if pr_number is not None: if not _text(pr_head_sha): reasons.append(f"owning PR #{pr_number} head could not be observed") elif _text(head_sha) and pr_head_sha != head_sha: reasons.append( f"owning PR #{pr_number} head {pr_head_sha} does not equal local " f"head {head_sha}" ) # ── AC7: nothing else claims this work ── reasons.extend( _competing_lock_reasons( competing_live_locks, issue_number=issue_number, branch_name=branch_name, worktree_path=worktree_path, ) ) other_branches = [ name for name in (candidate_branches or ()) if _text(name) and _text(name) != _text(branch_name) ] if other_branches: reasons.append( "other branches already carry this issue marker: " + ", ".join(sorted(other_branches)) ) if reasons: return _result(REFUSED, reasons, **extra) return _result( RENEWAL_SANCTIONED, [ f"exact owner '{recorded_identity}' ({recorded_profile}) proved " f"ownership of issue #{issue_number} on branch '{branch_name}' from " f"worktree '{worktree_path}'; local, remote" + (f", and PR #{pr_number}" if pr_number is not None else "") + f" heads all equal {head_sha}; lease expired at " f"{prior_expires_at or 'unknown'}" ], **extra, ) def owning_pr_renewal_evidence( assessment: Mapping[str, Any] | None, ) -> dict[str, Any] | None: """Server-derived proof of the open PR a sanctioned renewal already owns. The mirror of ``issue_lock_recovery.owning_pr_recovery_evidence`` (#755) for the renewal disposition. An exact-owner renewal of a published branch is, by construction, renewal of work that already has an open PR — so the duplicate-work gate's linked-open-PR blocker would otherwise discard every sanctioned renewal, exactly as it once discarded every sanctioned recovery. Returns ``None`` unless renewal was actually granted and the evidence names one owning PR whose head agrees with both the local and remote heads the assessor accepted. Nothing is caller-supplied: every field is copied from evidence built out of durable lock state plus live git/Gitea observation. Renewal has no descendant case — it requires the local, remote, and PR heads to be equal — so there is only one head to report. """ if not isinstance(assessment, Mapping): return None if assessment.get("outcome") != RENEWAL_SANCTIONED: return None if not assessment.get("renewal_sanctioned"): return None evidence = assessment.get("evidence") or {} branch_name = _text(evidence.get("branch_name")) pr_head = _text(evidence.get("pr_head_sha")) local_head = _text(evidence.get("head_sha")) remote_head = _text(evidence.get("remote_head_sha")) raw_pr_number = evidence.get("pr_number") if raw_pr_number is None or not branch_name or not pr_head: return None # The assessor already required these to agree. Re-check, so a truncated or # hand-built evidence map can never authorize an exemption. if pr_head != local_head or pr_head != remote_head: return None try: pr_number = int(raw_pr_number) issue_number = int(evidence.get("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": pr_head, "accepted_head": pr_head, "head_relation": "equal", } def build_renewal_record( assessment: Mapping[str, Any] | None, *, renewed_at: str, new_expires_at: str, ) -> dict[str, Any]: """Durable audit record for a sanctioned renewal (#760 AC9). Records both sides of the transition — prior PID and expiry, replacement PID and new expiry — so a renewed lock is never mistakable for an original claim, and so the evidence the waiver was granted on stays inspectable. """ data = dict(assessment or {}) evidence = dict(data.get("evidence") or {}) recorded_claimant = dict(evidence.get("recorded_claimant") or {}) return { "renewed": bool(data.get("renewal_sanctioned")), "renewed_at": renewed_at, "prior_pid": evidence.get("prior_pid"), "prior_pid_alive": evidence.get("prior_pid_alive"), "prior_expires_at": evidence.get("prior_expires_at"), "replacement_pid": evidence.get("replacement_pid"), "new_expires_at": new_expires_at, "identity": recorded_claimant.get("username"), "profile": recorded_claimant.get("profile"), "branch_name": evidence.get("branch_name"), "worktree_path": evidence.get("worktree_path"), "head_sha": evidence.get("head_sha"), "remote_head_sha": evidence.get("remote_head_sha"), "pr_head_sha": evidence.get("pr_head_sha"), "pr_number": evidence.get("pr_number"), "reason": "expired lease renewed by its exact recorded owner", "proof": list(data.get("reasons") or []), } def format_renewal_refusal(assessment: Mapping[str, Any] | None) -> str: """One-line refusal summary for a blocked caller.""" data = dict(assessment or {}) reasons = list(data.get("reasons") or []) if not reasons: return "exact-owner lease renewal was not available (no evidence recorded)" return "exact-owner lease renewal refused: " + "; ".join(reasons)