#!/usr/bin/env python3 """runner_evidence.py — runtime execution placement evidence (RP2/#265). Canonical rule: THE HEAVY RUNNER IS THE DESKTOP PC, NOT THE PI. A desktop-heavy stage executed on the Pi must NOT count as valid acceptance. This module captures ACTUAL runtime evidence — not configuration inference: - hostname of the executing machine - runner identity (from CI env vars when present, else "local") - runner labels/capabilities - execution class (desktop-heavy / pi-lightweight) - stage, process id, issue, research process id, timestamp The evidence record travels with the stage result and is validated by the placement gate BEFORE a stage result can be accepted. """ from __future__ import annotations import os import socket import time from datetime import datetime, timezone # Hostname substrings that identify the Pi fleet. Conservative: match on # the runner name labels actually registered in Gitea, plus obvious names. PI_IDENTIFIERS = ("rune-pi", "rune-infra-pi", "raspberry", "-pi") # Desktop runner identity (the only valid executor for desktop-heavy stages). DESKTOP_RUNNER_LABEL = "rune-ci-desktop" # Stages that require desktop-heavy execution (cognition, research, coding). DESKTOP_HEAVY_STAGES = { "INWARD", "OUTWARD", "APPLICABILITY", "INNOVATION", "ADVERSARIAL", "SYNTHESIS", "QUALIFY", } # Lightweight forge/control work that MAY legitimately run on the Pi. PI_LIGHTWEIGHT_OPERATIONS = { "issue_metadata", "label_change", "artifact_metadata", "process_state", "eligibility_check", "state_transition", } def _is_pi(name: str) -> bool: lowered = (name or "").lower() return any(marker in lowered for marker in PI_IDENTIFIERS) def capture_execution_evidence( stage: str, issue: int | None = None, process_id: str | None = None, execution_class: str = "desktop-heavy", ) -> dict: """Capture WHERE this stage actually ran, from runtime facts. Never trust configuration alone — capture the live hostname and any runner identity present in the environment. """ hostname = socket.gethostname() runner_name = os.environ.get("RUNNER_NAME") or os.environ.get("GITEA_RUNNER_NAME") or ("local:" + hostname) runner_labels = [l.strip() for l in os.environ.get("RUNNER_LABELS", "").split(",") if l.strip()] or [runner_name] return { "runner_hostname": hostname, "runner_name": runner_name, "runner_labels": runner_labels, "execution_class": execution_class, "stage": stage, "process_id": process_id or f"pid:{os.getpid()}", "issue_id": issue, "research_process_id": process_id, "timestamp": datetime.now(timezone.utc).isoformat(), "monotonic": time.monotonic(), } def validate_placement(evidence: dict) -> tuple[bool, list[str]]: """Deterministic placement gate (henxel). Returns (accepted, violations). A desktop-heavy stage executed on the Pi is REJECTED — no silent fallback, no configuration-only inference. """ violations = [] stage = evidence.get("stage", "") klass = evidence.get("execution_class", "desktop-heavy") # Evidence completeness — identity must be actually captured for field in ("runner_hostname", "runner_name", "runner_labels", "execution_class", "stage", "timestamp"): if not evidence.get(field): violations.append(f"placement evidence missing field: {field}") if klass == "desktop-heavy": identity = evidence.get("runner_name", "") + " " + evidence.get("runner_hostname", "") if _is_pi(identity) or any(_is_pi(l) for l in evidence.get("runner_labels", [])): violations.append( f"PLACEMENT VIOLATION: desktop-heavy stage '{stage}' executed on Pi " f"(runner={evidence.get('runner_name')}, host={evidence.get('runner_hostname')}). " f"Rejected as valid acceptance — the heavy runner is the desktop PC, not the Pi." ) # Desktop identity = Gitea runner label (rune-ci-desktop), the Gitea # runner NAME (rune-desktop), or a verified local desktop execution. # Pi rejection above already caught Pi markers — this is the allow-side. labels = " ".join(evidence.get("runner_labels", [])) if ( DESKTOP_RUNNER_LABEL not in identity and "rune-desktop" not in identity and DESKTOP_RUNNER_LABEL not in labels and "local:" not in evidence.get("runner_name", "") ): violations.append( f"runner '{evidence.get('runner_name')}' is not '{DESKTOP_RUNNER_LABEL}' " f"nor the registered rune-desktop runner, and not a verified local " f"desktop execution" ) return (len(violations) == 0, violations) def summarize_for_artifact(evidence: dict) -> dict: """Strip transient fields for embedding in a stage artifact.""" return {k: v for k, v in evidence.items() if k != "monotonic"}