Files
rune/scripts/runner_evidence.py
T
Nick 851d66c43c fix(RP11): placement gate accepts the registered desktop runner identity
CI exposed a latent validator defect masked locally: the desktop runner's
Gitea-registered name is 'rune-desktop' (label 'rune-ci-desktop'), but
validate_placement required the LABEL string inside runner_name — so every
CI-executed stage was PLACEMENT_REJECTED while local runs passed (local:
prefix path).

Fix: desktop identity = label in name/labels, OR the registered rune-desktop
runner name, OR verified local execution. Pi rejection semantics unchanged
(regression-tested: rune-pi/rune-infra-pi still rejected via name, hostname,
and labels).

Found via RP11 PR CI — exactly what RP12's post-cleanup re-run is designed
to catch, caught one run early.
2026-09-09 11:32:35 +01:00

127 lines
5.0 KiB
Python

#!/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"}