Share review-workflow-load and review-decision-lock state across MCP daemon process pools via a user-private durable store keyed by profile identity (not host-global /tmp), with TTL and fail-closed profile checks.
322 lines
12 KiB
Python
322 lines
12 KiB
Python
"""Canonical review-merge workflow load proof for reviewer mutations (#389, #403, #559)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import mcp_session_state
|
|
import review_workflow_boundary as boundary
|
|
|
|
WORKFLOW_REL_PATH = (
|
|
"skills/llm-project-workflow/workflows/review-merge-pr.md"
|
|
)
|
|
SCHEMA_REL_PATH = (
|
|
"skills/llm-project-workflow/schemas/review-merge-final-report.md"
|
|
)
|
|
TASK_MODE = "review-merge-pr"
|
|
LOAD_TOOL_NAME = "gitea_load_review_workflow"
|
|
|
|
_REVIEW_WORKFLOW_LOAD: dict | None = None
|
|
|
|
|
|
def compute_content_hash(text: str) -> str:
|
|
"""Short deterministic hash for workflow/schema version proof."""
|
|
return hashlib.sha256((text or "").encode("utf-8")).hexdigest()[:12]
|
|
|
|
|
|
def _read_text(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def _canonical_paths(project_root: str) -> tuple[Path, Path]:
|
|
root = Path(project_root)
|
|
workflow = root / WORKFLOW_REL_PATH
|
|
schema = root / SCHEMA_REL_PATH
|
|
if not workflow.is_file():
|
|
raise FileNotFoundError(f"canonical workflow missing: {workflow}")
|
|
if not schema.is_file():
|
|
raise FileNotFoundError(f"final report schema missing: {schema}")
|
|
return workflow, schema
|
|
|
|
|
|
def build_canonical_workflow_metadata(
|
|
project_root: str,
|
|
*,
|
|
prompt_text: str | None = None,
|
|
) -> dict:
|
|
"""Load workflow + schema from disk and compute proof metadata."""
|
|
workflow_path, schema_path = _canonical_paths(project_root)
|
|
workflow_text = _read_text(workflow_path)
|
|
schema_text = _read_text(schema_path)
|
|
workflow_hash = compute_content_hash(workflow_text)
|
|
schema_hash = compute_content_hash(schema_text)
|
|
conflict, conflict_reasons = assess_prompt_conflict(prompt_text)
|
|
return {
|
|
"workflow_source": WORKFLOW_REL_PATH,
|
|
"workflow_path": str(workflow_path),
|
|
"task_mode": TASK_MODE,
|
|
"workflow_hash": workflow_hash,
|
|
"workflow_version": workflow_hash,
|
|
"final_report_schema_path": SCHEMA_REL_PATH,
|
|
"final_report_schema_hash": schema_hash,
|
|
"prompt_conflicts_with_workflow": conflict,
|
|
"prompt_conflict_reasons": conflict_reasons,
|
|
"load_tool": LOAD_TOOL_NAME,
|
|
}
|
|
|
|
|
|
def assess_prompt_conflict(prompt_text: str | None) -> tuple[bool, list[str]]:
|
|
"""Detect obvious task-mode conflicts between prompt and review workflow."""
|
|
if not (prompt_text or "").strip():
|
|
return False, []
|
|
text = prompt_text.lower()
|
|
reasons: list[str] = []
|
|
conflicting = (
|
|
(r"\bwork[- ]issue\b", "work-issue author mode"),
|
|
(r"\bcreate[- ]issue\b", "create-issue mode"),
|
|
(r"\bauthor/coder\b", "author/coder mode"),
|
|
(r"\breconcile[- ]landed\b", "reconcile-landed mode"),
|
|
)
|
|
for pattern, label in conflicting:
|
|
if re.search(pattern, text):
|
|
reasons.append(
|
|
f"active prompt appears to request {label} while loading "
|
|
f"{TASK_MODE} workflow"
|
|
)
|
|
return bool(reasons), reasons
|
|
|
|
|
|
def _session_binding_fields() -> dict:
|
|
"""Capture profile identity used to share state across daemon processes."""
|
|
env_lock = (os.environ.get(mcp_session_state.SESSION_PROFILE_LOCK_ENV) or "").strip()
|
|
profile_name = (os.environ.get("GITEA_MCP_PROFILE") or "").strip()
|
|
remote = (os.environ.get("GITEA_MCP_REMOTE") or "").strip() or None
|
|
identity = mcp_session_state.current_profile_identity(
|
|
profile_name=profile_name,
|
|
session_profile_lock=env_lock,
|
|
)
|
|
return {
|
|
"session_profile": profile_name or identity,
|
|
"session_profile_lock": env_lock or identity,
|
|
"profile_identity": identity,
|
|
"remote": remote,
|
|
}
|
|
|
|
|
|
def _persist_workflow_load(record: dict | None) -> dict | None:
|
|
"""Write durable workflow-load proof (or clear it)."""
|
|
binding = _session_binding_fields()
|
|
if record is None:
|
|
mcp_session_state.clear_state(
|
|
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
|
remote=binding.get("remote"),
|
|
profile_identity=binding.get("profile_identity"),
|
|
)
|
|
return None
|
|
payload = dict(record)
|
|
payload.update({
|
|
k: v for k, v in binding.items() if v is not None
|
|
})
|
|
return mcp_session_state.save_state(
|
|
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
|
payload=payload,
|
|
remote=payload.get("remote"),
|
|
org=payload.get("org"),
|
|
repo=payload.get("repo"),
|
|
profile_identity=payload.get("profile_identity"),
|
|
)
|
|
|
|
|
|
def _load_durable_workflow_load() -> dict | None:
|
|
binding = _session_binding_fields()
|
|
return mcp_session_state.load_state(
|
|
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
|
remote=binding.get("remote"),
|
|
profile_identity=binding.get("profile_identity"),
|
|
)
|
|
|
|
|
|
def _active_workflow_load() -> dict | None:
|
|
"""Prefer in-process cache; fall back to durable shared state (#559)."""
|
|
global _REVIEW_WORKFLOW_LOAD
|
|
if _REVIEW_WORKFLOW_LOAD is not None:
|
|
return _REVIEW_WORKFLOW_LOAD
|
|
durable = _load_durable_workflow_load()
|
|
if durable is not None:
|
|
_REVIEW_WORKFLOW_LOAD = dict(durable)
|
|
return _REVIEW_WORKFLOW_LOAD
|
|
|
|
|
|
def record_review_workflow_load(
|
|
project_root: str,
|
|
*,
|
|
prompt_text: str | None = None,
|
|
) -> dict:
|
|
"""Record workflow load proof for the current MCP session (durable + memory)."""
|
|
global _REVIEW_WORKFLOW_LOAD
|
|
meta = build_canonical_workflow_metadata(
|
|
project_root, prompt_text=prompt_text)
|
|
boundary_state = boundary.assess_boundary_status(project_root)
|
|
binding = _session_binding_fields()
|
|
record = {
|
|
**meta,
|
|
"session_pid": os.getpid(),
|
|
"loaded": True,
|
|
"boundary_status": boundary_state.get("boundary_status"),
|
|
"boundary_clean": boundary_state.get("boundary_clean"),
|
|
"pre_review_command_count": boundary_state.get("pre_review_command_count"),
|
|
"boundary_violation_count": boundary_state.get("boundary_violation_count"),
|
|
"boundary_reasons": list(boundary_state.get("reasons") or []),
|
|
**{k: v for k, v in binding.items() if v is not None},
|
|
}
|
|
persisted = _persist_workflow_load(record)
|
|
_REVIEW_WORKFLOW_LOAD = dict(persisted or record)
|
|
return dict(_REVIEW_WORKFLOW_LOAD)
|
|
|
|
|
|
def clear_review_workflow_load() -> None:
|
|
"""Test helper and review_pr session reset."""
|
|
global _REVIEW_WORKFLOW_LOAD
|
|
_REVIEW_WORKFLOW_LOAD = None
|
|
_persist_workflow_load(None)
|
|
boundary.clear_pre_review_commands()
|
|
|
|
|
|
def workflow_load_status(project_root: str | None = None) -> dict:
|
|
"""Non-throwing status for capability/runtime reports."""
|
|
load = _active_workflow_load()
|
|
if load is None:
|
|
return {
|
|
"workflow_load_proof_present": False,
|
|
"workflow_load_valid": False,
|
|
"workflow_source": None,
|
|
"workflow_hash": None,
|
|
"final_report_schema_path": SCHEMA_REL_PATH,
|
|
"reasons": [
|
|
f"{LOAD_TOOL_NAME} has not been called in this session "
|
|
"(fail closed for reviewer mutations)"
|
|
],
|
|
}
|
|
reasons = _session_validation_reasons(load, project_root)
|
|
boundary_reasons = boundary.boundary_blockers(project_root)
|
|
if boundary_reasons:
|
|
reasons = list(reasons) + boundary_reasons
|
|
return {
|
|
"workflow_load_proof_present": True,
|
|
"workflow_load_valid": not reasons,
|
|
"workflow_source": load.get("workflow_source"),
|
|
"workflow_hash": load.get("workflow_hash"),
|
|
"task_mode": load.get("task_mode"),
|
|
"final_report_schema_path": load.get("final_report_schema_path"),
|
|
"final_report_schema_hash": load.get("final_report_schema_hash"),
|
|
"prompt_conflicts_with_workflow": load.get(
|
|
"prompt_conflicts_with_workflow"),
|
|
"session_pid": load.get("session_pid"),
|
|
"writer_pid": load.get("writer_pid"),
|
|
"profile_identity": load.get("profile_identity"),
|
|
"boundary_status": load.get("boundary_status"),
|
|
"boundary_clean": load.get("boundary_clean"),
|
|
"workflow_load_helper_result": boundary.workflow_load_helper_result(
|
|
load, project_root),
|
|
"reasons": reasons,
|
|
}
|
|
|
|
|
|
def _session_validation_reasons(
|
|
load: dict,
|
|
project_root: str | None,
|
|
) -> list[str]:
|
|
"""Validate durable/in-memory load proof for this session identity (#559)."""
|
|
reasons: list[str] = []
|
|
# Cross-process daemon pools are allowed when profile identity matches.
|
|
# Reject only when the stored profile identity conflicts with this process.
|
|
binding = _session_binding_fields()
|
|
stored_identity = (
|
|
load.get("session_profile_lock")
|
|
or load.get("profile_identity")
|
|
or ""
|
|
).strip()
|
|
active_identity = (binding.get("profile_identity") or "").strip()
|
|
if (
|
|
stored_identity
|
|
and active_identity
|
|
and stored_identity != active_identity
|
|
and active_identity != "unknown-profile"
|
|
and stored_identity != "unknown-profile"
|
|
):
|
|
reasons.append(
|
|
"workflow load proof profile identity mismatch "
|
|
f"(stored={stored_identity!r}, active={active_identity!r}; fail closed)"
|
|
)
|
|
return reasons
|
|
|
|
# Expired durable records are treated as absent.
|
|
identity_reasons = mcp_session_state.identity_match_reasons(
|
|
load,
|
|
remote=binding.get("remote") or load.get("remote"),
|
|
org=load.get("org"),
|
|
repo=load.get("repo"),
|
|
profile_identity=active_identity or stored_identity,
|
|
)
|
|
# Filter out remote-mismatch noise when remote was not bound at load time.
|
|
for reason in identity_reasons:
|
|
if "missing recorded_at" in reason or "expired" in reason or "future" in reason:
|
|
reasons.append(reason)
|
|
elif "profile identity mismatch" in reason:
|
|
reasons.append(reason)
|
|
if reasons:
|
|
return reasons
|
|
|
|
if load.get("prompt_conflicts_with_workflow"):
|
|
reasons.extend(load.get("prompt_conflict_reasons") or [
|
|
"active prompt conflicts with loaded review-merge workflow"
|
|
])
|
|
if project_root:
|
|
try:
|
|
current = build_canonical_workflow_metadata(project_root)
|
|
except OSError as exc:
|
|
reasons.append(f"cannot re-verify workflow hash: {exc}")
|
|
return reasons
|
|
if current["workflow_hash"] != load.get("workflow_hash"):
|
|
reasons.append(
|
|
"stored workflow hash is stale; reload via "
|
|
f"{LOAD_TOOL_NAME} (fail closed)"
|
|
)
|
|
if current["final_report_schema_hash"] != load.get(
|
|
"final_report_schema_hash"):
|
|
reasons.append(
|
|
"stored final-report schema hash is stale; reload via "
|
|
f"{LOAD_TOOL_NAME} (fail closed)"
|
|
)
|
|
return reasons
|
|
|
|
|
|
def review_workflow_load_blockers(
|
|
project_root: str | None = None,
|
|
) -> list[str]:
|
|
"""Reasons reviewer mutations must fail closed."""
|
|
boundary_reasons = boundary.boundary_blockers(project_root)
|
|
load = _active_workflow_load()
|
|
if boundary_reasons and load is None:
|
|
return boundary_reasons
|
|
status = workflow_load_status(project_root)
|
|
if not status.get("workflow_load_proof_present"):
|
|
return list(status.get("reasons") or []) + boundary_reasons
|
|
if not status.get("workflow_load_valid"):
|
|
return list(status.get("reasons") or [])
|
|
return []
|
|
|
|
|
|
def recovery_handoff_without_replay() -> list[str]:
|
|
"""Safe next-step lines that must not include approve/merge replay."""
|
|
return [
|
|
"Reload the canonical workflow via gitea_load_review_workflow, then "
|
|
"rerun the full review-merge workflow from inventory.",
|
|
"Do not call gitea_submit_pr_review, gitea_mark_final_review_decision, "
|
|
"or gitea_merge_pr until workflow-load proof is present.",
|
|
"Do not include approve/merge replay commands in the recovery handoff.",
|
|
]
|