Merge remote-tracking branch 'prgs/master' into feat/issue-507-controller-thread-ledger
# Conflicts: # gitea_mcp_server.py
This commit is contained in:
+360
-16
@@ -696,6 +696,39 @@ def _verify_role_mutation_workspace(
|
||||
task: str | None = None,
|
||||
) -> str:
|
||||
"""Bind reviewer/merger mutations to the active namespace workspace (#510)."""
|
||||
# Check running runtimes to prevent stale mutations
|
||||
try:
|
||||
if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
|
||||
config = gitea_config.load_config()
|
||||
required_permission = task_capability_map.required_permission(task) if task else None
|
||||
matching_profiles = []
|
||||
if required_permission and config and "profiles" in config:
|
||||
for p_name, p_data in config["profiles"].items():
|
||||
p_allowed = p_data.get("allowed_operations") or []
|
||||
p_forbidden = p_data.get("forbidden_operations") or []
|
||||
p_allowed_n = []
|
||||
for op in p_allowed:
|
||||
try:
|
||||
p_allowed_n.append(gitea_config.normalize_operation(op))
|
||||
except Exception:
|
||||
pass
|
||||
p_forbidden_n = []
|
||||
for op in p_forbidden:
|
||||
try:
|
||||
p_forbidden_n.append(gitea_config.normalize_operation(op))
|
||||
except Exception:
|
||||
pass
|
||||
ok, _ = gitea_config.check_operation(required_permission, p_allowed_n, p_forbidden_n)
|
||||
if ok:
|
||||
matching_profiles.append(p_name)
|
||||
runtime_reasons = _check_mcp_runtimes_diagnostics(task or "unknown", matching_profiles)
|
||||
if runtime_reasons:
|
||||
raise RuntimeError("; ".join(runtime_reasons))
|
||||
except Exception as exc:
|
||||
if "stale-runtime:" in str(exc):
|
||||
raise RuntimeError(str(exc))
|
||||
pass
|
||||
|
||||
role = _effective_workspace_role()
|
||||
git_state = issue_lock_worktree.read_worktree_git_state(
|
||||
_resolve_preflight_workspace_path(worktree_path)
|
||||
@@ -771,6 +804,7 @@ import task_capability_map # noqa: E402
|
||||
import review_proofs # noqa: E402
|
||||
import review_workflow_boundary # noqa: E402
|
||||
import review_workflow_load # noqa: E402
|
||||
import mcp_session_state # noqa: E402
|
||||
import agent_temp_artifacts
|
||||
import issue_lock_worktree # noqa: E402
|
||||
import issue_lock_provenance # noqa: E402
|
||||
@@ -795,6 +829,13 @@ import review_merge_state_machine # noqa: E402
|
||||
import pr_work_lease # noqa: E402
|
||||
import native_mcp_preference # noqa: E402
|
||||
import thread_state_ledger_validator # noqa: E402
|
||||
import master_parity_gate # noqa: E402
|
||||
|
||||
# Master-parity baseline (#420): the commit this server process started at.
|
||||
# Captured once so that, at mutation time, we can detect when the on-disk
|
||||
# master has advanced past the running code and fail closed until restart.
|
||||
# Read-only operations are never blocked by staleness.
|
||||
_STARTUP_PARITY = master_parity_gate.capture_startup_parity(PROJECT_ROOT)
|
||||
import worktree_cleanup_audit # noqa: E402
|
||||
|
||||
|
||||
@@ -2448,37 +2489,110 @@ _REVIEW_ACTIONS = {
|
||||
|
||||
_TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"})
|
||||
|
||||
# In-process only (#211): never persist to /tmp — host-global files are
|
||||
# spoofable by any local process and go stale across sessions.
|
||||
# Durable across MCP daemon process pools (#559), but never under /tmp (#211):
|
||||
# host-global temp files are spoofable. Persistence uses the user-private
|
||||
# cache directory from mcp_session_state (mode 0o700 / files 0o600), keyed by
|
||||
# remote + profile identity with TTL.
|
||||
_REVIEW_DECISION_LOCK: dict | None = None
|
||||
|
||||
|
||||
def _decision_lock_binding(lock: dict | None = None) -> dict:
|
||||
"""Resolve key fields for durable decision-lock storage."""
|
||||
profile = get_profile()
|
||||
profile_name = (profile.get("profile_name") or "").strip()
|
||||
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||
stored_lock = ((lock or {}).get("session_profile_lock") or "").strip()
|
||||
session_lock = env_lock or stored_lock or profile_name
|
||||
remote = ((lock or {}).get("remote") or "").strip() or None
|
||||
org = ((lock or {}).get("org") or (lock or {}).get("ready_org") or "").strip() or None
|
||||
repo = ((lock or {}).get("repo") or (lock or {}).get("ready_repo") or "").strip() or None
|
||||
return {
|
||||
"session_profile": profile_name,
|
||||
"session_profile_lock": session_lock,
|
||||
"profile_identity": mcp_session_state.current_profile_identity(
|
||||
profile_name=profile_name,
|
||||
session_profile_lock=session_lock,
|
||||
),
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
}
|
||||
|
||||
|
||||
def _load_review_decision_lock():
|
||||
"""Load decision lock from memory, falling back to durable session state."""
|
||||
global _REVIEW_DECISION_LOCK
|
||||
if _REVIEW_DECISION_LOCK is not None:
|
||||
return _REVIEW_DECISION_LOCK
|
||||
binding = _decision_lock_binding()
|
||||
# Profile-keyed durable file; remote/org/repo validated from payload (#559).
|
||||
durable = mcp_session_state.load_state(
|
||||
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||
profile_identity=binding.get("profile_identity"),
|
||||
)
|
||||
if durable is not None:
|
||||
_REVIEW_DECISION_LOCK = dict(durable)
|
||||
return _REVIEW_DECISION_LOCK
|
||||
|
||||
|
||||
def _save_review_decision_lock(data):
|
||||
"""Persist decision lock to memory + durable shared state (#559)."""
|
||||
global _REVIEW_DECISION_LOCK
|
||||
_REVIEW_DECISION_LOCK = data
|
||||
if data is None:
|
||||
binding = _decision_lock_binding(_REVIEW_DECISION_LOCK)
|
||||
mcp_session_state.clear_state(
|
||||
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||
profile_identity=binding.get("profile_identity"),
|
||||
)
|
||||
_REVIEW_DECISION_LOCK = None
|
||||
return
|
||||
payload = dict(data)
|
||||
binding = _decision_lock_binding(payload)
|
||||
payload.setdefault("session_pid", os.getpid())
|
||||
payload["writer_pid"] = os.getpid()
|
||||
payload["session_profile"] = binding["session_profile"] or payload.get(
|
||||
"session_profile"
|
||||
)
|
||||
payload["session_profile_lock"] = binding["session_profile_lock"]
|
||||
payload["profile_identity"] = binding["profile_identity"]
|
||||
if binding.get("remote") and not payload.get("remote"):
|
||||
payload["remote"] = binding["remote"]
|
||||
persisted = mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||
payload=payload,
|
||||
remote=payload.get("remote"),
|
||||
org=payload.get("org") or payload.get("ready_org"),
|
||||
repo=payload.get("repo") or payload.get("ready_repo"),
|
||||
profile_identity=payload.get("profile_identity"),
|
||||
)
|
||||
_REVIEW_DECISION_LOCK = dict(persisted or payload)
|
||||
|
||||
|
||||
def _review_decision_session_reasons(lock: dict | None) -> list[str]:
|
||||
"""Reject locks that do not belong to this MCP process/session."""
|
||||
"""Reject locks that do not belong to this MCP session identity (#559).
|
||||
|
||||
Different daemon PIDs in the same IDE session pool are allowed when the
|
||||
profile identity and remote match. Spoofed/stale locks still fail closed.
|
||||
"""
|
||||
if lock is None:
|
||||
return []
|
||||
reasons = []
|
||||
if lock.get("session_pid") != os.getpid():
|
||||
reasons.append(
|
||||
"review decision lock was created in a different process "
|
||||
"(fail closed)"
|
||||
)
|
||||
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||
stored_lock = (lock.get("session_profile_lock") or "").strip()
|
||||
stored_lock = (lock.get("session_profile_lock") or lock.get("profile_identity") or "").strip()
|
||||
if env_lock and stored_lock and env_lock != stored_lock:
|
||||
reasons.append(
|
||||
"review decision lock session profile lock mismatch (fail closed)"
|
||||
)
|
||||
# TTL / identity checks from durable envelope fields.
|
||||
for reason in mcp_session_state.identity_match_reasons(
|
||||
lock,
|
||||
remote=lock.get("remote"),
|
||||
org=lock.get("org") or lock.get("ready_org"),
|
||||
repo=lock.get("repo") or lock.get("ready_repo"),
|
||||
profile_identity=env_lock or stored_lock,
|
||||
):
|
||||
if "profile identity mismatch" in reason or "expired" in reason or "future" in reason or "missing recorded_at" in reason:
|
||||
reasons.append(reason)
|
||||
return reasons
|
||||
|
||||
|
||||
@@ -2489,11 +2603,12 @@ def init_review_decision_lock(remote: str | None, task: str | None, force: bool
|
||||
if not force:
|
||||
lock = _load_review_decision_lock()
|
||||
if lock is not None:
|
||||
if lock.get("remote") == remote and lock.get("session_pid") == os.getpid():
|
||||
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||
stored_lock = (lock.get("session_profile_lock") or "").strip()
|
||||
if not env_lock or not stored_lock or env_lock == stored_lock:
|
||||
return
|
||||
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
|
||||
stored_lock = (lock.get("session_profile_lock") or "").strip()
|
||||
same_remote = lock.get("remote") == remote
|
||||
same_profile = (not env_lock or not stored_lock or env_lock == stored_lock)
|
||||
if same_remote and same_profile and not _review_decision_session_reasons(lock):
|
||||
return
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
profile = get_profile()
|
||||
profile_name = (profile.get("profile_name") or "").strip()
|
||||
@@ -2508,6 +2623,10 @@ def init_review_decision_lock(remote: str | None, task: str | None, force: bool
|
||||
"session_pid": os.getpid(),
|
||||
"session_profile": profile_name,
|
||||
"session_profile_lock": session_lock,
|
||||
"profile_identity": mcp_session_state.current_profile_identity(
|
||||
profile_name=profile_name,
|
||||
session_profile_lock=session_lock,
|
||||
),
|
||||
"final_review_decision_ready": False,
|
||||
"ready_pr_number": None,
|
||||
"ready_action": None,
|
||||
@@ -4626,6 +4745,34 @@ def gitea_reconcile_merged_cleanups(
|
||||
)
|
||||
|
||||
delete_capability_allowed = not _profile_operation_gate("gitea.branch.delete")
|
||||
|
||||
# #534: discover reviewer scratch trees and active leases for those PRs.
|
||||
scratch_candidates = merged_cleanup_reconcile.discover_reviewer_scratch_worktrees(
|
||||
PROJECT_ROOT
|
||||
)
|
||||
active_reviewer_leases: dict[int, bool] = {}
|
||||
pr_states: dict[int, dict] = {}
|
||||
for scratch in scratch_candidates:
|
||||
pr_num = int(scratch["pr_number"])
|
||||
if pr_num in active_reviewer_leases:
|
||||
continue
|
||||
try:
|
||||
comments = api_get_all(f"{base}/issues/{pr_num}/comments", auth)
|
||||
except Exception:
|
||||
comments = []
|
||||
lease = reviewer_pr_lease.find_active_reviewer_lease(
|
||||
comments, pr_number=pr_num
|
||||
)
|
||||
active_reviewer_leases[pr_num] = bool(lease)
|
||||
try:
|
||||
pr_live = api_request("GET", f"{base}/pulls/{pr_num}", auth)
|
||||
except Exception:
|
||||
pr_live = {}
|
||||
pr_states[pr_num] = {
|
||||
"merged": bool((pr_live or {}).get("merged") or (pr_live or {}).get("merged_at")),
|
||||
"closed": (pr_live or {}).get("state") == "closed",
|
||||
}
|
||||
|
||||
report = merged_cleanup_reconcile.build_reconciliation_report(
|
||||
project_root=PROJECT_ROOT,
|
||||
closed_prs=merged_closed,
|
||||
@@ -4633,6 +4780,8 @@ def gitea_reconcile_merged_cleanups(
|
||||
remote_branch_exists=remote_branch_exists,
|
||||
head_on_master=head_on_master,
|
||||
delete_capability_allowed=delete_capability_allowed,
|
||||
active_reviewer_leases=active_reviewer_leases,
|
||||
pr_states=pr_states,
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
@@ -4676,6 +4825,14 @@ def gitea_reconcile_merged_cleanups(
|
||||
)
|
||||
actions.append({"action": "remove_local_worktree", **result})
|
||||
|
||||
for scratch in report.get("reviewer_scratch_entries") or []:
|
||||
if not scratch.get("safe_to_remove_worktree"):
|
||||
continue
|
||||
result = merged_cleanup_reconcile.remove_reviewer_scratch_worktree(
|
||||
PROJECT_ROOT, scratch.get("worktree_path") or ""
|
||||
)
|
||||
actions.append({"action": "remove_reviewer_scratch_worktree", **result})
|
||||
|
||||
report["dry_run"] = False
|
||||
report["executed"] = True
|
||||
report["actions"] = actions
|
||||
@@ -5436,15 +5593,41 @@ def _try_auto_switch_for_operation(op: str, host: str | None = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _current_master_parity() -> dict:
|
||||
"""Assess this process's code against the on-disk master HEAD (#420)."""
|
||||
current_head = master_parity_gate.read_git_head(PROJECT_ROOT)
|
||||
return master_parity_gate.assess_master_parity(_STARTUP_PARITY, current_head)
|
||||
|
||||
|
||||
def _master_parity_block(op: str) -> list[str]:
|
||||
"""Fail-closed staleness reasons for a mutation *op* (#420).
|
||||
|
||||
Read-only operations (``gitea.read``) are never blocked: a stale server can
|
||||
still be inspected. Any other (mutating) operation is refused when the
|
||||
on-disk master has definitively advanced past the running code, since newly
|
||||
merged capability gates would not yet be loaded in memory.
|
||||
"""
|
||||
if op == "gitea.read":
|
||||
return []
|
||||
return master_parity_gate.parity_block_reasons(_current_master_parity())
|
||||
|
||||
|
||||
def _profile_operation_gate(op: str) -> list[str]:
|
||||
"""Profile permission check for a single gated operation (#126, #216).
|
||||
"""Profile permission check for a single gated operation (#126, #216, #420).
|
||||
|
||||
Issue discussion comments are gated separately from the gitea.pr.*
|
||||
review/merge family: listing requires ``gitea.read``, creating requires
|
||||
``gitea.issue.comment``. Closing a PR requires the distinct
|
||||
``gitea.pr.close`` (#216). Returns a list of block reasons (empty =
|
||||
allowed); an unreadable profile fails closed.
|
||||
|
||||
A mutating operation is additionally refused when the server code is stale
|
||||
relative to master (#420), so a long-running process cannot bypass a
|
||||
capability gate that has since been merged.
|
||||
"""
|
||||
stale_reasons = _master_parity_block(op)
|
||||
if stale_reasons:
|
||||
return stale_reasons
|
||||
try:
|
||||
profile = get_profile()
|
||||
except Exception as exc:
|
||||
@@ -7567,6 +7750,24 @@ def gitea_get_runtime_context(
|
||||
PROJECT_ROOT),
|
||||
}
|
||||
|
||||
parity = _current_master_parity()
|
||||
result["master_parity"] = {
|
||||
"in_parity": parity["in_parity"],
|
||||
"stale": parity["stale"],
|
||||
"restart_required": parity["restart_required"],
|
||||
"startup_head": parity["startup_head"],
|
||||
"current_head": parity["current_head"],
|
||||
"summary": master_parity_gate.format_parity(parity),
|
||||
"mutation_gate_enforced": not master_parity_gate.gate_disabled(),
|
||||
}
|
||||
if parity["stale"] and not master_parity_gate.gate_disabled():
|
||||
safe_next_action = (
|
||||
"Server code is stale relative to master; restart the Gitea MCP "
|
||||
"server to load current capability gates before mutating. "
|
||||
f"({master_parity_gate.format_parity(parity)})"
|
||||
)
|
||||
result["safe_next_action"] = safe_next_action
|
||||
|
||||
if reveal and h:
|
||||
result["server"] = gitea_url(h, "").rstrip("/")
|
||||
|
||||
@@ -7574,6 +7775,43 @@ def gitea_get_runtime_context(
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_assess_master_parity(
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
) -> dict:
|
||||
"""Read-only: is the running server code in parity with the on-disk master?
|
||||
|
||||
The MCP server loads its capability-gate code into memory at startup;
|
||||
``master`` advancing (e.g. a newly merged security gate) does not take
|
||||
effect until the process restarts. This tool compares the commit the
|
||||
process started at against the current workspace ``HEAD`` and reports
|
||||
whether a restart is required before mutations are safe again (#420).
|
||||
|
||||
Never mutates and makes no network calls. Read-only operations are never
|
||||
blocked by staleness; only mutating operations fail closed while stale.
|
||||
|
||||
Returns:
|
||||
dict with 'in_parity', 'stale', 'restart_required', 'startup_head',
|
||||
'current_head', 'mutation_gate_enforced', 'summary', 'reasons', and a
|
||||
'report' recovery payload when stale.
|
||||
"""
|
||||
parity = _current_master_parity()
|
||||
enforced = not master_parity_gate.gate_disabled()
|
||||
out = {
|
||||
"in_parity": parity["in_parity"],
|
||||
"stale": parity["stale"],
|
||||
"restart_required": parity["restart_required"],
|
||||
"determinable": parity["determinable"],
|
||||
"startup_head": parity["startup_head"],
|
||||
"current_head": parity["current_head"],
|
||||
"mutation_gate_enforced": enforced,
|
||||
"summary": master_parity_gate.format_parity(parity),
|
||||
"reasons": parity["reasons"],
|
||||
"process_root": PROJECT_ROOT,
|
||||
}
|
||||
if parity["stale"] and enforced:
|
||||
out["report"] = master_parity_gate.parity_report(parity)
|
||||
return out
|
||||
def gitea_record_pre_review_command(
|
||||
command: str,
|
||||
cwd: str | None = None,
|
||||
@@ -8621,6 +8859,102 @@ def gitea_route_task_session(
|
||||
)
|
||||
|
||||
|
||||
def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> list[str]:
|
||||
"""Check running runtimes and return errors if they are missing or stale."""
|
||||
import subprocess
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
reasons = []
|
||||
code_path = os.path.join(PROJECT_ROOT, "gitea_mcp_server.py")
|
||||
if not os.path.exists(code_path):
|
||||
return reasons
|
||||
|
||||
code_mtime = datetime.fromtimestamp(os.path.getmtime(code_path))
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["ps", "-o", "pid,lstart,command", "-ax"],
|
||||
capture_output=True, text=True, check=True
|
||||
)
|
||||
except Exception as exc:
|
||||
return [f"stale-runtime: failed to list running processes: {exc}"]
|
||||
|
||||
self_pid = os.getpid()
|
||||
self_stale = False
|
||||
|
||||
running_profiles = {}
|
||||
for line in proc.stdout.splitlines()[1:]:
|
||||
line = line.strip()
|
||||
if not line or "mcp_server.py" not in line:
|
||||
continue
|
||||
|
||||
parts = line.split(None, 6)
|
||||
if len(parts) < 7:
|
||||
continue
|
||||
pid_str = parts[0]
|
||||
lstart_str = " ".join(parts[1:6])
|
||||
|
||||
try:
|
||||
pid = int(pid_str)
|
||||
start_time = datetime.strptime(lstart_str, "%a %b %d %H:%M:%S %Y")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
try:
|
||||
env_proc = subprocess.run(
|
||||
["ps", "eww", str(pid)],
|
||||
capture_output=True, text=True, check=True
|
||||
)
|
||||
env_out = env_proc.stdout
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
profile = "gitea-default"
|
||||
match = re.search(r'\bGITEA_MCP_PROFILE=([^\s]+)', env_out)
|
||||
if match:
|
||||
profile = match.group(1)
|
||||
|
||||
is_stale = start_time < code_mtime
|
||||
if pid == self_pid and is_stale:
|
||||
self_stale = True
|
||||
|
||||
if profile not in running_profiles or start_time > running_profiles[profile]["start_time"]:
|
||||
running_profiles[profile] = {
|
||||
"pid": pid,
|
||||
"start_time": start_time,
|
||||
"is_stale": is_stale
|
||||
}
|
||||
|
||||
if self_stale:
|
||||
reasons.append(
|
||||
"stale-runtime: The active Gitea MCP server process is stale (running code from before changes were merged). "
|
||||
"Please fully restart the Gitea MCP server (e.g. touch /Users/jasonwalker/.gemini/config/mcp_config.json) and retry."
|
||||
)
|
||||
|
||||
if matching_profiles:
|
||||
any_running = False
|
||||
any_fresh = False
|
||||
for mp in matching_profiles:
|
||||
if mp in running_profiles:
|
||||
any_running = True
|
||||
if not running_profiles[mp]["is_stale"]:
|
||||
any_fresh = True
|
||||
break
|
||||
if not any_running:
|
||||
reasons.append(
|
||||
f"stale-runtime: None of the matching profiles for task '{task}' ({matching_profiles}) are running in the OS. "
|
||||
"Please restart the MCP server to ensure they are spawned."
|
||||
)
|
||||
elif not any_fresh:
|
||||
reasons.append(
|
||||
f"stale-runtime: All matching profiles for task '{task}' ({matching_profiles}) are running but stale. "
|
||||
"Please fully restart the Gitea MCP server and retry."
|
||||
)
|
||||
|
||||
return reasons
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_resolve_task_capability(
|
||||
task: str,
|
||||
@@ -8731,6 +9065,16 @@ def gitea_resolve_task_capability(
|
||||
configured = len(matching_profiles) > 0
|
||||
available_in_session = allowed_in_current_session
|
||||
|
||||
if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
|
||||
runtime_reasons = _check_mcp_runtimes_diagnostics(task, matching_profiles)
|
||||
if runtime_reasons:
|
||||
restart_required = True
|
||||
reason_msg = "; ".join(runtime_reasons)
|
||||
next_safe_action = (
|
||||
"stale-runtime: Gitea MCP runtime conflict or missing process detected. "
|
||||
"Please fully restart the Gitea MCP server and retry."
|
||||
)
|
||||
|
||||
if not allowed_in_current_session:
|
||||
if configured and switching:
|
||||
restart_required = True
|
||||
|
||||
Reference in New Issue
Block a user