feat: gate reviewer mutations on canonical workflow load proof (Closes #389)
Add gitea_load_review_workflow and in-process session proof required before gitea_submit_pr_review, gitea_mark_final_review_decision, and gitea_merge_pr. Capability and runtime context report workflow-load status; stale or missing proof fails closed with recovery handoff text that forbids approve/merge replay.
This commit is contained in:
+78
-1
@@ -474,6 +474,7 @@ import role_session_router # noqa: E402
|
||||
import role_namespace_gate # noqa: E402
|
||||
import task_capability_map # noqa: E402
|
||||
import review_proofs # noqa: E402
|
||||
import review_workflow_load # noqa: E402
|
||||
import agent_temp_artifacts
|
||||
import issue_lock_worktree # noqa: E402
|
||||
import issue_claim_heartbeat # noqa: E402
|
||||
@@ -1636,6 +1637,7 @@ def init_review_decision_lock(remote: str | None, task: str | None):
|
||||
"""Seed read-only-until-ready state for reviewer PR review tasks."""
|
||||
if task != "review_pr":
|
||||
return
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
profile = get_profile()
|
||||
profile_name = (profile.get("profile_name") or "").strip()
|
||||
session_lock = (
|
||||
@@ -1661,6 +1663,11 @@ def init_review_decision_lock(remote: str | None, task: str | None):
|
||||
})
|
||||
|
||||
|
||||
def _review_workflow_load_gate_reasons() -> list[str]:
|
||||
"""Fail closed when canonical review workflow was not loaded (#389)."""
|
||||
return review_workflow_load.review_workflow_load_blockers(PROJECT_ROOT)
|
||||
|
||||
|
||||
def check_review_decision_gate(
|
||||
pr_number: int,
|
||||
action: str,
|
||||
@@ -1671,7 +1678,10 @@ def check_review_decision_gate(
|
||||
repo: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Fail closed unless validation completed and the final decision is ready."""
|
||||
reasons = []
|
||||
reasons = list(_review_workflow_load_gate_reasons())
|
||||
if reasons:
|
||||
reasons.extend(review_workflow_load.recovery_handoff_without_replay())
|
||||
return reasons
|
||||
lock = _load_review_decision_lock()
|
||||
if lock is None:
|
||||
reasons.append(
|
||||
@@ -2027,6 +2037,7 @@ def _evaluate_pr_review_submission(
|
||||
"""Shared gate chain for live submit and dry-run review tools."""
|
||||
verify_preflight_purity(remote)
|
||||
action = (action or "").strip().lower()
|
||||
workflow_blockers = _review_workflow_load_gate_reasons() if live else []
|
||||
result = {
|
||||
"requested_action": action,
|
||||
"performed": False,
|
||||
@@ -2042,6 +2053,10 @@ def _evaluate_pr_review_submission(
|
||||
"reasons": [],
|
||||
}
|
||||
reasons = result["reasons"]
|
||||
if workflow_blockers:
|
||||
reasons.extend(workflow_blockers)
|
||||
reasons.extend(review_workflow_load.recovery_handoff_without_replay())
|
||||
return result
|
||||
|
||||
if action not in _REVIEW_ACTIONS:
|
||||
reasons.append(
|
||||
@@ -2226,6 +2241,13 @@ def gitea_mark_final_review_decision(
|
||||
}
|
||||
org = resolved_org
|
||||
repo = resolved_repo
|
||||
workflow_blockers = _review_workflow_load_gate_reasons()
|
||||
if workflow_blockers:
|
||||
return {
|
||||
"marked_ready": False,
|
||||
"reasons": workflow_blockers + (
|
||||
review_workflow_load.recovery_handoff_without_replay()),
|
||||
}
|
||||
hard_stop = terminal_review_hard_stop_reasons(pr_number, "mark_ready")
|
||||
if hard_stop:
|
||||
return {"marked_ready": False, "reasons": hard_stop}
|
||||
@@ -2809,6 +2831,7 @@ def gitea_merge_pr(
|
||||
available. Never secrets.
|
||||
"""
|
||||
verify_preflight_purity(remote)
|
||||
workflow_blockers = _review_workflow_load_gate_reasons()
|
||||
do = (do or "").strip().lower()
|
||||
result = {
|
||||
"performed": False,
|
||||
@@ -2826,6 +2849,10 @@ def gitea_merge_pr(
|
||||
"reasons": [],
|
||||
}
|
||||
reasons = result["reasons"]
|
||||
if workflow_blockers:
|
||||
reasons.extend(workflow_blockers)
|
||||
reasons.extend(review_workflow_load.recovery_handoff_without_replay())
|
||||
return result
|
||||
|
||||
# Gate 1 — valid merge method (no API call on a bad method).
|
||||
if do not in _MERGE_METHODS:
|
||||
@@ -4175,6 +4202,8 @@ _PROJECT_SKILLS = {
|
||||
"steps": [
|
||||
"Resolve task first: gitea_resolve_task_capability(task='review_pr') "
|
||||
"to confirm reviewer namespace and avoid author-profile blocks.",
|
||||
"Load canonical workflow proof with gitea_load_review_workflow "
|
||||
"before any review/merge mutation (#389).",
|
||||
"Verify reviewer identity with gitea_whoami; the PR author "
|
||||
"must be a different user.",
|
||||
"Reconcile live queue state FIRST (do not trust prior handoffs): "
|
||||
@@ -4978,6 +5007,8 @@ def gitea_get_runtime_context(
|
||||
"preflight_workspace": preflight.get("preflight_workspace"),
|
||||
"session_capabilities": session_capabilities,
|
||||
"shell_health": native_mcp_preference.shell_health_status(),
|
||||
"workflow_load_proof": review_workflow_load.workflow_load_status(
|
||||
PROJECT_ROOT),
|
||||
}
|
||||
|
||||
if reveal and h:
|
||||
@@ -4986,6 +5017,42 @@ def gitea_get_runtime_context(
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_load_review_workflow(
|
||||
prompt_text: str | None = None,
|
||||
) -> dict:
|
||||
"""Load and record canonical review-merge workflow proof for this session (#389).
|
||||
|
||||
Read-only with respect to Gitea API; records in-process workflow source/hash
|
||||
proof required before reviewer review or merge mutations.
|
||||
"""
|
||||
try:
|
||||
recorded = review_workflow_load.record_review_workflow_load(
|
||||
PROJECT_ROOT, prompt_text=prompt_text)
|
||||
except OSError as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"loaded": False,
|
||||
"reasons": [str(exc)],
|
||||
"recovery_handoff": review_workflow_load.recovery_handoff_without_replay(),
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"loaded": True,
|
||||
"workflow_source": recorded["workflow_source"],
|
||||
"task_mode": recorded["task_mode"],
|
||||
"workflow_hash": recorded["workflow_hash"],
|
||||
"workflow_version": recorded["workflow_version"],
|
||||
"final_report_schema_path": recorded["final_report_schema_path"],
|
||||
"final_report_schema_hash": recorded["final_report_schema_hash"],
|
||||
"prompt_conflicts_with_workflow": recorded[
|
||||
"prompt_conflicts_with_workflow"],
|
||||
"prompt_conflict_reasons": recorded.get("prompt_conflict_reasons") or [],
|
||||
"workflow_load_proof_present": True,
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_list_profiles() -> dict:
|
||||
"""Read-only: list all Gitea MCP profiles with redacted metadata.
|
||||
@@ -6041,6 +6108,16 @@ def gitea_resolve_task_capability(
|
||||
}
|
||||
if reason_msg:
|
||||
result["reason"] = reason_msg
|
||||
if task in ("review_pr", "merge_pr"):
|
||||
result["workflow_load_proof"] = review_workflow_load.workflow_load_status(
|
||||
PROJECT_ROOT)
|
||||
if not result["workflow_load_proof"].get("workflow_load_valid"):
|
||||
guidance = (
|
||||
"Call gitea_load_review_workflow before any reviewer review "
|
||||
"or merge mutation."
|
||||
)
|
||||
if guidance not in task_role_guidance:
|
||||
task_role_guidance.append(guidance)
|
||||
role_session_router.sync_route_from_capability(result)
|
||||
was_terminal = capability_stop_terminal.is_active()
|
||||
terminal = capability_stop_terminal.sync_from_capability_result(result)
|
||||
|
||||
Reference in New Issue
Block a user