501 lines
15 KiB
Python
501 lines
15 KiB
Python
import base64
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any
|
|
|
|
from audit_event_reconciliation import redact_sensitive_text
|
|
import issue_lock_provenance
|
|
|
|
DEFAULT_LOCK_TTL_HOURS = 24
|
|
AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
|
|
|
|
_SANCTIONED_ROLES = {"author", "reviewer", "merger", "reconciler", "controller"}
|
|
|
|
|
|
def default_lock_dir() -> str:
|
|
override = (os.environ.get("GITEA_ISSUE_LOCK_DIR") or "").strip()
|
|
if override:
|
|
return override
|
|
cache_dir = (os.environ.get("GITEA_MCP_SESSION_STATE_DIR") or "").strip()
|
|
if cache_dir:
|
|
return os.path.join(cache_dir, "locks")
|
|
user_cache = os.path.expanduser("~/.cache")
|
|
return os.path.join(user_cache, "gitea-tools", "locks")
|
|
|
|
|
|
def session_pointer_path(lock_dir: str | None = None) -> str:
|
|
root = (lock_dir or default_lock_dir()).strip()
|
|
return os.path.join(root, f"session-{os.getpid()}.json")
|
|
|
|
|
|
def _ensure_lock_dir(lock_dir: str | None = None) -> str:
|
|
root = (lock_dir or default_lock_dir()).strip()
|
|
os.makedirs(root, mode=0o700, exist_ok=True)
|
|
return root
|
|
|
|
|
|
def flock_path(json_path: str) -> str:
|
|
return f"{json_path}.lock"
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _exclusive_file_lock(lock_path: str):
|
|
import fcntl
|
|
|
|
_ensure_lock_dir(os.path.dirname(lock_path))
|
|
with open(lock_path, "a+") as fh:
|
|
try:
|
|
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
|
yield
|
|
finally:
|
|
try:
|
|
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def lock_file_path(
|
|
*,
|
|
remote: str,
|
|
org: str,
|
|
repo: str,
|
|
issue_number: int,
|
|
lock_dir: str | None = None,
|
|
) -> str:
|
|
root = (lock_dir or default_lock_dir()).strip()
|
|
slug = f"{remote}-{org}-{repo}-issue-{issue_number}.json"
|
|
safe_slug = re.sub(r"[^A-Za-z0-9._-]", "_", slug)
|
|
return os.path.join(root, safe_slug)
|
|
|
|
|
|
def read_lock_file(path: str) -> dict[str, Any] | None:
|
|
if not path or not os.path.isfile(path):
|
|
return None
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|
data = json.load(fh)
|
|
if isinstance(data, dict):
|
|
return data
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def save_lock_file(path: str, data: dict[str, Any]) -> None:
|
|
_ensure_lock_dir(os.path.dirname(path))
|
|
tmp_path = f"{path}.tmp.{os.getpid()}"
|
|
with open(tmp_path, "w", encoding="utf-8") as fh:
|
|
json.dump(data, fh, indent=2)
|
|
fh.flush()
|
|
os.fsync(fh.fileno())
|
|
os.replace(tmp_path, path)
|
|
|
|
|
|
def lock_generation(lock: dict[str, Any] | None) -> int:
|
|
if not isinstance(lock, dict):
|
|
return 0
|
|
try:
|
|
return int(lock.get("lock_generation") or 0)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
|
|
def bind_session_lock(
|
|
lock_data: dict[str, Any],
|
|
lock_dir: str | None = None,
|
|
*,
|
|
expected_generation: int | None = None,
|
|
renewal_sanctioned: bool = False,
|
|
recovery_sanctioned: bool = False,
|
|
) -> str:
|
|
remote = str(lock_data.get("remote") or "")
|
|
org = str(lock_data.get("org") or "")
|
|
repo = str(lock_data.get("repo") or "")
|
|
issue_number = int(lock_data.get("issue_number") or 0)
|
|
if not remote or not org or not repo or issue_number <= 0:
|
|
raise ValueError("lock record must include remote, org, repo, and issue_number")
|
|
|
|
root = _ensure_lock_dir(lock_dir)
|
|
path = lock_file_path(
|
|
remote=remote,
|
|
org=org,
|
|
repo=repo,
|
|
issue_number=issue_number,
|
|
lock_dir=root,
|
|
)
|
|
record = dict(lock_data)
|
|
record["lock_file_path"] = path
|
|
record["session_pid"] = os.getpid()
|
|
record.setdefault("pid", os.getpid())
|
|
|
|
pointer = {
|
|
"pid": os.getpid(),
|
|
"lock_file_path": path,
|
|
"issue_number": issue_number,
|
|
"branch_name": record.get("branch_name"),
|
|
"remote": remote,
|
|
"org": org,
|
|
"repo": repo,
|
|
}
|
|
sentinel = flock_path(path)
|
|
try:
|
|
with _exclusive_file_lock(sentinel):
|
|
existing = read_lock_file(path)
|
|
overwrite_block = assess_foreign_lock_overwrite(
|
|
existing, record, recovery_sanctioned=recovery_sanctioned
|
|
)
|
|
if overwrite_block:
|
|
raise RuntimeError(overwrite_block)
|
|
lease_block = assess_same_issue_lease_conflict(
|
|
existing,
|
|
issue_number=issue_number,
|
|
branch_name=str(record.get("branch_name") or ""),
|
|
worktree_path=str(record.get("worktree_path") or ""),
|
|
renewal_sanctioned=renewal_sanctioned,
|
|
recovery_sanctioned=recovery_sanctioned,
|
|
)
|
|
if lease_block:
|
|
raise RuntimeError(lease_block)
|
|
|
|
current_gen = lock_generation(existing)
|
|
if expected_generation is not None and current_gen != expected_generation:
|
|
raise RuntimeError(
|
|
f"compare-and-swap generation mismatch on issue #{issue_number}: "
|
|
f"expected {expected_generation}, observed {current_gen} (fail closed)"
|
|
)
|
|
|
|
record["lock_generation"] = current_gen + 1
|
|
if not record.get("lock_provenance"):
|
|
provenance_source = (
|
|
issue_lock_provenance.SOURCE_RECOVER_DIRTY_ORPHANED
|
|
if recovery_sanctioned
|
|
else issue_lock_provenance.SOURCE_LOCK_ISSUE
|
|
)
|
|
record["lock_provenance"] = issue_lock_provenance.build_lock_provenance(
|
|
issue_number=issue_number,
|
|
source=provenance_source,
|
|
branch_name=record.get("branch_name"),
|
|
worktree_path=record.get("worktree_path"),
|
|
generation=record["lock_generation"],
|
|
)
|
|
|
|
save_lock_file(path, record)
|
|
save_lock_file(session_pointer_path(root), pointer)
|
|
except Exception:
|
|
raise
|
|
return path
|
|
|
|
|
|
def read_session_issue_lock(lock_dir: str | None = None) -> dict[str, Any] | None:
|
|
root = (lock_dir or default_lock_dir()).strip()
|
|
pointer = read_lock_file(session_pointer_path(root))
|
|
if not pointer:
|
|
return None
|
|
lock_path = str(pointer.get("lock_file_path") or "").strip()
|
|
if not lock_path:
|
|
return None
|
|
return read_lock_file(lock_path)
|
|
|
|
|
|
def load_issue_lock(
|
|
*,
|
|
remote: str,
|
|
org: str,
|
|
repo: str,
|
|
issue_number: int,
|
|
lock_dir: str | None = None,
|
|
) -> dict[str, Any] | None:
|
|
return read_lock_file(
|
|
lock_file_path(
|
|
remote=remote,
|
|
org=org,
|
|
repo=repo,
|
|
issue_number=issue_number,
|
|
lock_dir=lock_dir,
|
|
)
|
|
)
|
|
|
|
|
|
def iter_lock_files(lock_dir: str | None = None) -> list[str]:
|
|
root = (lock_dir or default_lock_dir()).strip()
|
|
if not os.path.isdir(root):
|
|
return []
|
|
out: list[str] = []
|
|
for entry in os.listdir(root):
|
|
if entry.endswith(".json") and not entry.startswith("session-"):
|
|
out.append(os.path.join(root, entry))
|
|
return sorted(out)
|
|
|
|
|
|
def find_live_lock_for_branch(
|
|
branch_name: str,
|
|
lock_dir: str | None = None,
|
|
*,
|
|
now: datetime | None = None,
|
|
) -> dict[str, Any] | None:
|
|
target = (branch_name or "").strip()
|
|
if not target:
|
|
return None
|
|
for path in iter_lock_files(lock_dir):
|
|
lock = read_lock_file(path)
|
|
if not lock:
|
|
continue
|
|
lock_branch = str(lock.get("branch_name") or "").strip()
|
|
if lock_branch == target and is_lease_live(lock, now=now):
|
|
return lock
|
|
return None
|
|
|
|
|
|
def is_process_alive(pid: int | None) -> bool:
|
|
if pid is None or pid <= 0:
|
|
return False
|
|
try:
|
|
os.kill(pid, 0)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _lease_now(now: datetime | None = None) -> datetime:
|
|
if now is not None:
|
|
if now.tzinfo is None:
|
|
return now.replace(tzinfo=timezone.utc)
|
|
return now.astimezone(timezone.utc)
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _parse_lease_timestamp(text: str | None) -> datetime | None:
|
|
if not text:
|
|
return None
|
|
raw = str(text).strip()
|
|
if not raw:
|
|
return None
|
|
try:
|
|
dt = datetime.fromisoformat(raw)
|
|
if dt.tzinfo is None:
|
|
return dt.replace(tzinfo=timezone.utc)
|
|
return dt.astimezone(timezone.utc)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def lease_expires_at(lock: dict[str, Any] | None) -> datetime | None:
|
|
if not lock:
|
|
return None
|
|
lease = lock.get("work_lease")
|
|
if not isinstance(lease, dict):
|
|
return None
|
|
return _parse_lease_timestamp(lease.get("expires_at"))
|
|
|
|
|
|
def is_lease_expired(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
|
|
expires = lease_expires_at(lock)
|
|
if expires is None:
|
|
return False
|
|
return expires <= _lease_now(now)
|
|
|
|
|
|
def is_lease_live(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
|
|
return assess_lock_freshness(lock, now=now)["live"]
|
|
|
|
|
|
def assess_lock_freshness(
|
|
lock_data: dict[str, Any] | None,
|
|
*,
|
|
now: datetime | None = None,
|
|
) -> dict[str, Any]:
|
|
current = _lease_now(now)
|
|
if not lock_data:
|
|
return {
|
|
"status": "absent",
|
|
"live": False,
|
|
"stale": False,
|
|
"reason": "no lock record",
|
|
}
|
|
|
|
expires_at = lease_expires_at(lock_data)
|
|
lease = lock_data.get("work_lease")
|
|
heartbeat_at = _parse_lease_timestamp(lock_data.get("last_heartbeat_at"))
|
|
if heartbeat_at is None and isinstance(lease, dict):
|
|
heartbeat_at = _parse_lease_timestamp(lease.get("last_heartbeat_at"))
|
|
|
|
pid = lock_data.get("session_pid")
|
|
if pid is None:
|
|
pid = lock_data.get("pid")
|
|
if pid is None:
|
|
pid = lock_data.get("owner_pid")
|
|
pid_missing = pid is None or str(pid).strip() == ""
|
|
try:
|
|
pid_int = int(pid) if not pid_missing else None
|
|
if pid_int is not None and pid_int <= 0:
|
|
pid_missing = True
|
|
pid_int = None
|
|
except (TypeError, ValueError):
|
|
pid_missing = True
|
|
pid_int = None
|
|
pid_alive = is_process_alive(pid_int) if pid_int is not None else False
|
|
|
|
if expires_at and expires_at <= current:
|
|
return {
|
|
"status": "expired",
|
|
"live": False,
|
|
"stale": True,
|
|
"reason": f"lease expired at {expires_at.isoformat()}",
|
|
"pid_alive": pid_alive,
|
|
"pid_missing": pid_missing,
|
|
}
|
|
|
|
if pid_missing:
|
|
return {
|
|
"status": "malformed",
|
|
"live": False,
|
|
"stale": True,
|
|
"reason": (
|
|
"lock has no usable session pid; cannot prove live ownership "
|
|
"(PID-less locks are never live by missing expiry alone)"
|
|
),
|
|
"pid_alive": False,
|
|
"pid_missing": True,
|
|
"heartbeat_at": heartbeat_at.isoformat() if heartbeat_at else None,
|
|
"expires_at": expires_at.isoformat() if expires_at else None,
|
|
}
|
|
|
|
if pid_int is not None and not pid_alive:
|
|
return {
|
|
"status": "stale",
|
|
"live": False,
|
|
"stale": True,
|
|
"reason": f"owner pid {pid_int} is dead",
|
|
"pid_alive": False,
|
|
"pid_missing": False,
|
|
"pid": pid_int,
|
|
}
|
|
|
|
raw_status = (lock_data.get("status") or "").strip().lower()
|
|
if raw_status in {"active", "acquired", "locked"}:
|
|
return {
|
|
"status": "active",
|
|
"live": True,
|
|
"stale": False,
|
|
"reason": f"lock active (pid {pid_int})",
|
|
"pid_alive": True,
|
|
"pid_missing": False,
|
|
"pid": pid_int,
|
|
}
|
|
|
|
return {
|
|
"status": raw_status or "unknown",
|
|
"live": False,
|
|
"stale": True,
|
|
"reason": f"unrecognized lock status '{raw_status}'",
|
|
"pid_alive": pid_alive,
|
|
"pid_missing": False,
|
|
}
|
|
|
|
|
|
def assess_same_issue_lease_conflict(
|
|
existing_lock: dict[str, Any] | None,
|
|
*,
|
|
issue_number: int,
|
|
branch_name: str,
|
|
worktree_path: str,
|
|
now: datetime | None = None,
|
|
renewal_sanctioned: bool = False,
|
|
recovery_sanctioned: bool = False,
|
|
) -> str | None:
|
|
if not existing_lock:
|
|
return None
|
|
freshness = assess_lock_freshness(existing_lock, now=now)
|
|
if not freshness["live"]:
|
|
return None
|
|
if renewal_sanctioned or recovery_sanctioned:
|
|
return None
|
|
|
|
existing_wt = str(existing_lock.get("worktree_path") or "").strip()
|
|
target_wt = (worktree_path or "").strip()
|
|
if existing_wt and target_wt:
|
|
try:
|
|
same_wt = os.path.realpath(existing_wt) == os.path.realpath(target_wt)
|
|
except Exception:
|
|
same_wt = existing_wt == target_wt
|
|
if not same_wt:
|
|
owner_pid = existing_lock.get("session_pid") or existing_lock.get("pid")
|
|
return (
|
|
f"Issue #{issue_number} already has an active author_issue_work lease "
|
|
f"from worktree '{existing_wt}' (pid={owner_pid}; fail closed)"
|
|
)
|
|
|
|
existing_br = str(existing_lock.get("branch_name") or "").strip()
|
|
target_br = (branch_name or "").strip()
|
|
if existing_br and target_br and existing_br != target_br:
|
|
return (
|
|
f"Issue #{issue_number} already has an active lease on branch '{existing_br}' "
|
|
f"(cannot lock for branch '{target_br}'; fail closed)"
|
|
)
|
|
return None
|
|
|
|
|
|
def assess_foreign_lock_overwrite(
|
|
existing_lock: dict[str, Any] | None,
|
|
proposed_lock: dict[str, Any],
|
|
*,
|
|
now: datetime | None = None,
|
|
recovery_sanctioned: bool = False,
|
|
) -> str | None:
|
|
if not existing_lock:
|
|
return None
|
|
freshness = assess_lock_freshness(existing_lock, now=now)
|
|
|
|
ex_claimant = (
|
|
existing_lock.get("claimant")
|
|
or existing_lock.get("user")
|
|
or existing_lock.get("username")
|
|
or existing_lock.get("owner")
|
|
or ""
|
|
)
|
|
prop_claimant = (
|
|
proposed_lock.get("claimant")
|
|
or proposed_lock.get("user")
|
|
or proposed_lock.get("username")
|
|
or proposed_lock.get("owner")
|
|
or ""
|
|
)
|
|
ex_profile = (
|
|
existing_lock.get("profile")
|
|
or existing_lock.get("profile_name")
|
|
or existing_lock.get("execution_profile")
|
|
or ""
|
|
)
|
|
prop_profile = (
|
|
proposed_lock.get("profile")
|
|
or proposed_lock.get("profile_name")
|
|
or proposed_lock.get("execution_profile")
|
|
or ""
|
|
)
|
|
|
|
same_claimant = bool(
|
|
ex_claimant and prop_claimant and ex_claimant == prop_claimant
|
|
)
|
|
same_profile = bool(
|
|
ex_profile and prop_profile and ex_profile == prop_profile
|
|
)
|
|
|
|
if not same_claimant and not recovery_sanctioned:
|
|
return (
|
|
f"Foreign lock overwrite refused: existing lock belongs to claimant "
|
|
f"'{ex_claimant or 'unknown'}' (proposed: '{prop_claimant or 'unknown'}'); "
|
|
f"foreign locks may only be overwritten through explicit sanctioned "
|
|
f"recovery (fail closed)"
|
|
)
|
|
|
|
if freshness["live"] and not (same_claimant or same_profile) and not recovery_sanctioned:
|
|
return (
|
|
f"Foreign lock overwrite refused: existing lock is live "
|
|
f"(status={freshness['status']}) and belongs to '{ex_claimant or 'unknown'}'"
|
|
)
|
|
return None
|