Merge branch 'master' into feat/issue-610-live-remote-parity
This commit is contained in:
+183
-13
@@ -2017,6 +2017,7 @@ import issue_lock_provenance # noqa: E402
|
||||
import issue_lock_store # noqa: E402
|
||||
import issue_lock_adoption # noqa: E402
|
||||
import issue_lock_recovery # noqa: E402
|
||||
import issue_lock_renewal # noqa: E402
|
||||
import stacked_pr_support # noqa: E402
|
||||
import merge_approval_gate # noqa: E402
|
||||
import review_quarantine # noqa: E402 # #695 contaminated formal-review quarantine
|
||||
@@ -2316,7 +2317,12 @@ def _resolve_issue_lock_for_pr(
|
||||
return lock_data
|
||||
|
||||
|
||||
def _save_issue_lock(data: dict, *, expected_generation: int | None = None) -> str:
|
||||
def _save_issue_lock(
|
||||
data: dict,
|
||||
*,
|
||||
expected_generation: int | None = None,
|
||||
renewal_sanctioned: bool = False,
|
||||
) -> str:
|
||||
existing = issue_lock_store.load_issue_lock(
|
||||
remote=str(data.get("remote") or ""),
|
||||
org=str(data.get("org") or ""),
|
||||
@@ -2328,7 +2334,9 @@ def _save_issue_lock(data: dict, *, expected_generation: int | None = None) -> s
|
||||
raise RuntimeError(overwrite_block)
|
||||
try:
|
||||
return issue_lock_store.bind_session_lock(
|
||||
data, expected_generation=expected_generation
|
||||
data,
|
||||
expected_generation=expected_generation,
|
||||
renewal_sanctioned=renewal_sanctioned,
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Could not write issue lock file: {e}") from e
|
||||
@@ -2448,6 +2456,79 @@ def _evaluate_issue_lock_recovery(
|
||||
)
|
||||
|
||||
|
||||
def _evaluate_issue_lock_renewal(
|
||||
existing_lock: dict,
|
||||
*,
|
||||
issue_number: int,
|
||||
branch_name: str,
|
||||
worktree_path: str,
|
||||
remote: str,
|
||||
h: str | None,
|
||||
o: str,
|
||||
r: str,
|
||||
git_state: dict,
|
||||
) -> dict:
|
||||
"""Gather evidence and decide exact-owner renewal of an expired lease (#760).
|
||||
|
||||
Mirrors ``_evaluate_issue_lock_recovery``: every input is durable lock state
|
||||
or a live server-side observation (Gitea branch/PR inventory, git in the
|
||||
declared worktree, the local lock store). Nothing is reachable from an MCP
|
||||
caller's parameters, so no caller can assert its way into a renewal
|
||||
(#760 AC14).
|
||||
"""
|
||||
renewal_auth = _auth(h)
|
||||
try:
|
||||
renewal_branches = api_get_all(
|
||||
f"{repo_api_url(h, o, r)}/branches", renewal_auth
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not list branches to verify exact-owner lease renewal: {exc}"
|
||||
)
|
||||
|
||||
remote_head: str | None = None
|
||||
candidates: list[str] = []
|
||||
for entry in renewal_branches:
|
||||
entry_name = _branch_entry_name(entry)
|
||||
if entry_name == branch_name:
|
||||
remote_head = _branch_entry_commit_sha(entry)
|
||||
if issue_lock_adoption.branch_carries_issue_marker(entry_name, issue_number):
|
||||
candidates.append(entry_name)
|
||||
|
||||
pr_head: str | None = None
|
||||
pr_number: int | None = None
|
||||
for pull in _list_open_pulls(h, o, r, renewal_auth):
|
||||
pull_head = pull.get("head") or {}
|
||||
if str(pull_head.get("ref") or "") == branch_name:
|
||||
pr_head = pull_head.get("sha")
|
||||
pr_number = pull.get("number")
|
||||
break
|
||||
|
||||
claimant = _work_lease_claimant(h)
|
||||
return issue_lock_renewal.assess_exact_owner_lease_renewal(
|
||||
existing_lock,
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
worktree_path=worktree_path,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
identity=claimant.get("username"),
|
||||
profile=claimant.get("profile"),
|
||||
operation_type=AUTHOR_ISSUE_WORK_LEASE,
|
||||
current_branch=git_state.get("current_branch"),
|
||||
porcelain_status=git_state.get("porcelain_status") or "",
|
||||
worktree_exists=os.path.isdir(os.path.realpath(worktree_path)),
|
||||
head_sha=git_state.get("head_sha"),
|
||||
remote_head_sha=remote_head,
|
||||
pr_head_sha=pr_head,
|
||||
pr_number=pr_number,
|
||||
competing_live_locks=issue_lock_store.list_live_locks(),
|
||||
candidate_branches=candidates,
|
||||
current_pid=os.getpid(),
|
||||
)
|
||||
|
||||
|
||||
def _work_lease_claimant(host: str | None) -> dict:
|
||||
profile = get_profile()
|
||||
username = _IDENTITY_CACHE.get(host) if host else None
|
||||
@@ -3893,15 +3974,12 @@ def gitea_lock_issue(
|
||||
existing_issue_lock = _load_existing_issue_lock(
|
||||
remote=remote, org=o, repo=r, issue_number=issue_number
|
||||
)
|
||||
active_lease_block = issue_lock_store.assess_same_issue_lease_conflict(
|
||||
existing_issue_lock,
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
worktree_path=resolved_worktree,
|
||||
operation_type=AUTHOR_ISSUE_WORK_LEASE,
|
||||
)
|
||||
if active_lease_block:
|
||||
raise RuntimeError(active_lease_block)
|
||||
# #760: the competing-lease disposition is decided below, once the worktree
|
||||
# and Gitea evidence an exact-owner renewal depends on has actually been
|
||||
# observed. Deciding it here — before any of that exists — is what made the
|
||||
# same-owner allowance unreachable for an expired lease. The authoritative
|
||||
# check still runs inside bind_session_lock under the per-issue flock, so
|
||||
# moving this one later cannot widen the window for a competing writer.
|
||||
|
||||
# ── Stacked-PR base declaration (opt-in, #484) ──
|
||||
# Normal work leaves stacked_base_branch None → master-equivalent path.
|
||||
@@ -3967,6 +4045,53 @@ def gitea_lock_issue(
|
||||
recovery_sanctioned = bool(
|
||||
recovery_assessment and recovery_assessment.get("recovery_sanctioned")
|
||||
)
|
||||
|
||||
# ── Exact-owner renewal of an expired lease (#760) ──
|
||||
# The opposite trigger from #753 above: there the lease is unexpired and the
|
||||
# PID is dead; here the lease has expired while the recording daemon — which
|
||||
# is the long-lived MCP server, not the authoring task — may well still be
|
||||
# up. Only an expired lease is assessed, so a live foreign lease is never a
|
||||
# candidate (AC12) and dead-PID takeover keeps its existing conditions
|
||||
# (AC11). A refusal never raises: it withholds the waiver and leaves the
|
||||
# conflict check below to fail closed exactly as before.
|
||||
renewal_assessment: dict | None = None
|
||||
if (
|
||||
existing_issue_lock
|
||||
and existing_issue_lock.get("issue_number") == issue_number
|
||||
and issue_lock_store.is_lease_expired(existing_issue_lock)
|
||||
):
|
||||
renewal_assessment = _evaluate_issue_lock_renewal(
|
||||
existing_issue_lock,
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
worktree_path=resolved_worktree,
|
||||
remote=remote,
|
||||
h=h,
|
||||
o=o,
|
||||
r=r,
|
||||
git_state=git_state,
|
||||
)
|
||||
renewal_sanctioned = bool(
|
||||
renewal_assessment and renewal_assessment.get("renewal_sanctioned")
|
||||
)
|
||||
|
||||
active_lease_block = issue_lock_store.assess_same_issue_lease_conflict(
|
||||
existing_issue_lock,
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
worktree_path=resolved_worktree,
|
||||
operation_type=AUTHOR_ISSUE_WORK_LEASE,
|
||||
renewal_sanctioned=renewal_sanctioned,
|
||||
)
|
||||
if active_lease_block:
|
||||
reasons = [active_lease_block]
|
||||
# Name the exact missing evidence when this looked like a renewal, so a
|
||||
# blocked owner sees why rather than only the generic takeover text.
|
||||
if renewal_assessment and renewal_assessment.get("is_candidate"):
|
||||
reasons.append(
|
||||
issue_lock_renewal.format_renewal_refusal(renewal_assessment)
|
||||
)
|
||||
raise RuntimeError("; ".join(reasons))
|
||||
# #755: a sanctioned dead-session recovery always has an owning open PR —
|
||||
# that is what makes it a recovery rather than a fresh claim. Carry the
|
||||
# server-derived owning-PR evidence into the duplicate-work gate below so
|
||||
@@ -3978,6 +4103,14 @@ def gitea_lock_issue(
|
||||
if recovery_sanctioned
|
||||
else None
|
||||
)
|
||||
# #760: a sanctioned renewal owns its open PR for the same reason, so it
|
||||
# needs the same exemption. Without it the duplicate-work gate rejects every
|
||||
# renewal with "open PR already covers issue", which is the PR the lock
|
||||
# being renewed already owns. Withheld unless renewal was granted.
|
||||
if recovered_owning_pr is None and renewal_sanctioned:
|
||||
recovered_owning_pr = issue_lock_renewal.owning_pr_renewal_evidence(
|
||||
renewal_assessment
|
||||
)
|
||||
lock_assessment = issue_lock_worktree.assess_issue_lock_worktree(
|
||||
worktree_path=resolved_worktree,
|
||||
current_branch=git_state.get("current_branch"),
|
||||
@@ -3986,6 +4119,10 @@ def gitea_lock_issue(
|
||||
inspected_git_root=git_state.get("inspected_git_root"),
|
||||
base_branch=git_state.get("base_branch"),
|
||||
recovery_sanctioned=recovery_sanctioned,
|
||||
# #760: without this the renewal waiver was computed and then discarded
|
||||
# here — the exact-owner branch always carries commits, so it can never
|
||||
# be base-equivalent, and every real renewal failed at this gate.
|
||||
renewal_sanctioned=renewal_sanctioned,
|
||||
)
|
||||
if lock_assessment["block"]:
|
||||
reasons = list(lock_assessment.get("reasons") or [])
|
||||
@@ -3995,6 +4132,12 @@ def gitea_lock_issue(
|
||||
reasons.append(
|
||||
issue_lock_recovery.format_recovery_refusal(recovery_assessment)
|
||||
)
|
||||
# #760: same courtesy for a refused renewal, so an exact owner blocked
|
||||
# at this gate sees which piece of ownership evidence was missing.
|
||||
if renewal_assessment and renewal_assessment.get("is_candidate"):
|
||||
reasons.append(
|
||||
issue_lock_renewal.format_renewal_refusal(renewal_assessment)
|
||||
)
|
||||
raise RuntimeError(
|
||||
issue_lock_worktree.format_issue_lock_worktree_error(
|
||||
{**lock_assessment, "reasons": reasons}
|
||||
@@ -4070,18 +4213,35 @@ def gitea_lock_issue(
|
||||
recovery_assessment,
|
||||
recovered_at=_work_lease_timestamp(_work_lease_now()),
|
||||
)
|
||||
if renewal_sanctioned and renewal_assessment:
|
||||
# #760 AC9: record both sides of the transition — prior PID and expiry,
|
||||
# replacement PID and new expiry — so a renewed lock is auditable and
|
||||
# never reads as an original claim.
|
||||
data["lease_renewal"] = issue_lock_renewal.build_renewal_record(
|
||||
renewal_assessment,
|
||||
renewed_at=_work_lease_timestamp(_work_lease_now()),
|
||||
new_expires_at=str(work_lease.get("expires_at") or ""),
|
||||
)
|
||||
|
||||
# #772 AC5: a recovery replaces a claim another session already owned, so
|
||||
# its write is a compare-and-swap against the generation the assessment was
|
||||
# made on. Two replacement sessions that both observed the same dead owner
|
||||
# cannot both succeed — the second finds a moved generation and fails
|
||||
# closed. Ordinary first-time claims keep the unconditional write.
|
||||
# #760 uses the same compare-and-swap: a renewal also replaces a claim that
|
||||
# already existed on disk, so two sessions that both observed the same
|
||||
# expired lease cannot both win — the second finds a moved generation and
|
||||
# fails closed.
|
||||
expected_generation = (
|
||||
issue_lock_store.lock_generation(existing_issue_lock)
|
||||
if recovery_sanctioned
|
||||
if (recovery_sanctioned or renewal_sanctioned)
|
||||
else None
|
||||
)
|
||||
lock_file_path = _save_issue_lock(data, expected_generation=expected_generation)
|
||||
lock_file_path = _save_issue_lock(
|
||||
data,
|
||||
expected_generation=expected_generation,
|
||||
renewal_sanctioned=renewal_sanctioned,
|
||||
)
|
||||
lock_record = issue_lock_store.read_lock_file(lock_file_path) or data
|
||||
freshness = issue_lock_store.assess_lock_freshness(lock_record)
|
||||
competing = [
|
||||
@@ -4150,6 +4310,16 @@ def gitea_lock_issue(
|
||||
issue_number=issue_number,
|
||||
branch_name=branch_name,
|
||||
)
|
||||
if renewal_sanctioned:
|
||||
# #760 AC13: the renewal is visible in the native tool result, so an
|
||||
# owner never has to inspect the lock file to confirm what happened.
|
||||
result["lease_renewal"] = lock_record.get("lease_renewal")
|
||||
result["message"] = (
|
||||
f"Renewed the expired {AUTHOR_ISSUE_WORK_LEASE} lease on issue "
|
||||
f"#{issue_number} for its exact recorded owner, on branch "
|
||||
f"'{branch_name}' from worktree '{resolved_worktree}' "
|
||||
"(fail-closed check complete)."
|
||||
)
|
||||
if agent_artifacts:
|
||||
result["warnings"] = [
|
||||
"Agent temp artifacts at repo root (delete before implementation): "
|
||||
|
||||
Reference in New Issue
Block a user