fix(mcp): accept acquired merger-lease provenance and add owner finalization (Closes #742)
Root cause (confirmed on PR #740, session 33780-7168cbeeba58, marker 12354): merger_lease_adoption.SANCTIONED_PROVENANCE_SOURCES already contained SOURCE_ACQUIRE_MERGER, so the membership check passed, but is_sanctioned_session_lease() branched only for SOURCE_ADOPT and for {SOURCE_ACQUIRE, SOURCE_HEARTBEAT} and fell through to `return False` for a lease minted by gitea_acquire_merger_pr_lease. assess_mutation_lease_gate() therefore appended "in-session lease lacks sanctioned provenance" and gitea_merge_pr fail-closed on the merger's own freshly acquired lease. describe_session_lease_proof() likewise had no branch for that source and reported the lease as lease_proof_kind=unsanctioned. Separately, no merger-role owner-session operation existed to end that lease, so a failed merger lease could only expire. A. Acquired merger provenance is_sanctioned_session_lease() now accepts SOURCE_ACQUIRE_MERGER, but only via the new assess_acquired_merger_lease_integrity(), which fails closed unless the in-session record is complete and self-consistent: comment marker present and matching between provenance and session, non-empty session id, holder identity, merger profile, repository, valid PR number, and a normalized 40-hex candidate_head. describe_session_lease_proof() reports the explicit kind sanctioned_acquire_merger. Manual _SESSION_LEASE seeding, forged or incomplete records, mismatched fields, and unknown provenance all remain rejected; reviewer-acquire, reviewer-heartbeat, and merger-adoption paths are untouched. B. Merger owner-session finalization New native tool gitea_release_merger_pr_lease terminally releases or abandons a merger's own comment-backed lease when the merge does not occur. It is merger-only (gitea.read + gitea.pr.comment + gitea.pr.merge; reviewer profiles lack pr.merge) with an explicit capability-map task release_merger_pr_lease / gitea_release_merger_pr_lease bound to gitea.pr.comment + role merger, and it is listed as role-exclusive in the resolver. assess_merger_lease_finalization() verifies exact session ownership, repository, PR, candidate head vs live head, profile, identity, marker presence on the thread, and the native runtime token fingerprint recorded at acquisition; foreign-session release, reviewer-profile use, and any mismatch fail closed. Finalization appends a terminal lease marker and never edits or deletes ledger history; an already-terminal lease returns already_terminal and posts nothing. The PR, approval, decision lock, branch, and worktree are all preserved. gitea_release_reviewer_pr_lease is unchanged and is not repurposed for merger sessions. "abandoned" is added to reviewer_pr_lease._TERMINAL_PHASES; without it an abandoned marker would fall through the generic non-empty phase branch and keep re-arming the lease as active. Tests: new tests/test_merger_lease_finalization.py (38 tests, 11 subtests) covers all twelve acceptance criteria — provenance sanctioning, explicit proof kind, merge-gate acceptance, manual-seed and unknown-provenance rejection, profile/role/session/head/repository/fingerprint mismatches, owner release, foreign-session refusal, reviewer refusal, append-only idempotent finalization, no surviving lease after finalization, and no regression in adoption or reviewer-lease behavior. The defect was reproduced on clean baselinea8d2087first (is_sanctioned=False, proof_kind=unsanctioned, no finalization path). Validation: targeted lease/capability suites 272 passed. Full suite 3305 passed, 6 skipped, 2 failed — both failures (test_issue_702_review_findings_f1_f6 F1 recovery, reconciler supersession close) reproduce identically on clean baseline mastera8d2087in a throwaway worktree and are pre-existing #737 org/repo-forwarding drift, unrelated to this change. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -11585,6 +11585,11 @@ def gitea_acquire_merger_pr_lease(
|
||||
}, lease_provenance=merger_lease_adoption.build_lease_provenance(
|
||||
source=merger_lease_adoption.SOURCE_ACQUIRE_MERGER,
|
||||
comment_id=posted.get("id"),
|
||||
# #742: bind the lease to this native runtime so a later owner-session
|
||||
# finalization can prove it is the same daemon that acquired it.
|
||||
native_token_fingerprint=(
|
||||
mcp_daemon_guard.native_runtime_status().get("token_fingerprint")
|
||||
),
|
||||
))
|
||||
return {
|
||||
"success": True,
|
||||
@@ -11774,6 +11779,191 @@ def gitea_adopt_merger_pr_lease(
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_release_merger_pr_lease(
|
||||
pr_number: int,
|
||||
worktree: str,
|
||||
candidate_head: str,
|
||||
outcome: str = merger_lease_adoption.OUTCOME_RELEASED,
|
||||
reason: str | None = None,
|
||||
issue_number: int | None = None,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Terminally release or abandon this merger session's own PR lease (#742).
|
||||
|
||||
Sanctioned owner-session finalization for a comment-backed merger lease when
|
||||
the merge does **not** occur (non-retryable merge failure, aborted merge, or
|
||||
a blocker that ends the attempt). Without it a failed merger lease can only
|
||||
expire, blocking the queue for the remainder of its TTL.
|
||||
|
||||
Merger-only and owner-only. The caller must hold the exact in-session lease
|
||||
it is finalizing; foreign-session release, reviewer profiles, and mismatched
|
||||
repository / PR / candidate head / identity / profile / native runtime token
|
||||
fingerprint all fail closed. Reviewer sessions use
|
||||
``gitea_release_reviewer_pr_lease`` instead — that tool is never a merger
|
||||
path.
|
||||
|
||||
Finalization is append-only: it posts a terminal lease marker and never
|
||||
edits or deletes prior ledger comments. It is idempotent — an already
|
||||
terminal lease returns ``already_terminal`` and posts nothing. The PR, its
|
||||
approval, the review decision lock, the branch, and the worktree are all
|
||||
preserved; only the lease is ended.
|
||||
|
||||
Args:
|
||||
pr_number: PR whose merger lease to finalize.
|
||||
outcome: 'released' (clean end) or 'abandoned' (ended under a blocker).
|
||||
reason: Short finalization reason recorded in the marker.
|
||||
candidate_head: Pinned head SHA the lease was acquired against.
|
||||
|
||||
Returns:
|
||||
dict reporting finalization status, reasons, and the marker comment id.
|
||||
"""
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
return {
|
||||
"success": False,
|
||||
"finalized": False,
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
}
|
||||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||||
if comment_block:
|
||||
return {
|
||||
"success": False,
|
||||
"finalized": False,
|
||||
"reasons": comment_block,
|
||||
"permission_report": _permission_block_report("gitea.pr.comment"),
|
||||
}
|
||||
# Merger-role proof: reviewer profiles hold gitea.pr.comment but never
|
||||
# gitea.pr.merge, so this gate keeps the tool merger-only (#742).
|
||||
merge_block = _profile_operation_gate("gitea.pr.merge")
|
||||
if merge_block:
|
||||
return {
|
||||
"success": False,
|
||||
"finalized": False,
|
||||
"reasons": merge_block,
|
||||
"permission_report": _permission_block_report("gitea.pr.merge"),
|
||||
}
|
||||
|
||||
try:
|
||||
_verify_role_mutation_workspace(
|
||||
remote,
|
||||
worktree=worktree,
|
||||
task="release_merger_pr_lease",
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"finalized": False,
|
||||
"reasons": [str(e)],
|
||||
}
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
profile = get_profile()
|
||||
profile_name = profile.get("profile_name") or "unknown"
|
||||
identity = _authenticated_username(h) or profile.get("username") or ""
|
||||
repo_label = f"{o}/{r}"
|
||||
|
||||
session = reviewer_pr_lease.get_session_lease()
|
||||
comments = _fetch_pr_comments(
|
||||
pr_number, remote=remote, host=host, org=org, repo=repo)
|
||||
|
||||
pr_live = api_request(
|
||||
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {}
|
||||
live_head = str(
|
||||
(pr_live.get("head") or {}).get("sha") or pr_live.get("head_sha") or ""
|
||||
).strip()
|
||||
|
||||
assessment = merger_lease_adoption.assess_merger_lease_finalization(
|
||||
comments,
|
||||
pr_number=pr_number,
|
||||
session=session,
|
||||
actor_identity=identity,
|
||||
actor_profile=profile_name,
|
||||
actor_session_id=(session or {}).get("session_id"),
|
||||
repo=repo_label,
|
||||
worktree=worktree,
|
||||
candidate_head=candidate_head,
|
||||
live_head_sha=live_head or None,
|
||||
outcome=outcome,
|
||||
reason=reason,
|
||||
runtime_token_fingerprint=(
|
||||
mcp_daemon_guard.native_runtime_status().get("token_fingerprint")
|
||||
),
|
||||
issue_number=issue_number,
|
||||
)
|
||||
|
||||
if assessment.get("already_terminal"):
|
||||
# Idempotent: the lease is already terminal on the ledger. Clear the
|
||||
# in-session mirror so no stale proof survives, and post nothing.
|
||||
reviewer_pr_lease.clear_session_lease()
|
||||
return {
|
||||
"success": True,
|
||||
"finalized": False,
|
||||
"already_terminal": True,
|
||||
"pr_number": pr_number,
|
||||
"outcome": assessment.get("outcome"),
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
if not assessment.get("finalize_allowed"):
|
||||
return {
|
||||
"success": False,
|
||||
"finalized": False,
|
||||
"already_terminal": False,
|
||||
"pr_number": pr_number,
|
||||
"reasons": assessment.get("reasons") or [],
|
||||
}
|
||||
|
||||
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||||
with _audited(
|
||||
"comment_pr",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
pr_number=pr_number,
|
||||
request_metadata={"source": "release_merger_pr_lease"},
|
||||
):
|
||||
posted = api_request(
|
||||
"POST", comment_url, auth, {"body": assessment["finalization_body"]}
|
||||
)
|
||||
|
||||
if not isinstance(posted, dict) or not posted.get("id"):
|
||||
# The lease is still live on the ledger: keep the in-session mirror so
|
||||
# the owner can retry finalization rather than losing its own proof.
|
||||
return {
|
||||
"success": False,
|
||||
"finalized": False,
|
||||
"reasons": [
|
||||
"terminal lease marker POST failed or returned no comment id "
|
||||
"(lease left intact; retry finalization)"
|
||||
],
|
||||
}
|
||||
|
||||
reviewer_pr_lease.clear_session_lease()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"finalized": True,
|
||||
"already_terminal": False,
|
||||
"pr_number": pr_number,
|
||||
"outcome": assessment.get("outcome"),
|
||||
"finalization_reason": assessment.get("finalization_reason"),
|
||||
"finalization_comment_id": posted.get("id"),
|
||||
"finalized_lease_comment_id": assessment.get("lease_comment_id"),
|
||||
"candidate_head": assessment.get("candidate_head"),
|
||||
"repo": repo_label,
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_heartbeat_reviewer_pr_lease(
|
||||
pr_number: int,
|
||||
@@ -16482,6 +16672,8 @@ def gitea_resolve_task_capability(
|
||||
"gitea_acquire_merger_pr_lease",
|
||||
"adopt_merger_pr_lease",
|
||||
"gitea_adopt_merger_pr_lease",
|
||||
"release_merger_pr_lease",
|
||||
"gitea_release_merger_pr_lease",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
|
||||
Reference in New Issue
Block a user