CI Pipeline / best-practices (push) Successful in 8s
Auto-version and Release / release (push) Failing after 14s
CI Pipeline / documentation (push) Successful in 5s
CI Pipeline / lint (push) Failing after 34s
CI Pipeline / canary (push) Successful in 37s
CI Pipeline / type-check (push) Successful in 31s
CI Pipeline / gitea-offline (push) Successful in 16s
CI Pipeline / unit-tests (push) Successful in 1m4s
CI Pipeline / required-checks (push) Successful in 1s
deploy / deploy (push) Successful in 48s
CORRECTION 1: #314 reopened — recall_with_associations mode field defect CONFIRMED in current source and FIXED (mode: association_merged). CORRECTION 3: Gitea credential removed from remote URL (credential helper). TOKEN EXPOSED: yes (in diagnostic output). TOKEN ROTATED: not yet (requires Gitea web UI access). Remote URL now shows username only, no secret. CORRECTION 4: duplicate-release-commit defect ROOT CAUSE: convergence computed warranted bump from commits-since-tag without checking if master version already satisfies the bump. FIX: already-converged guard in version_convergence.py (master version > last tag → no commit). Repo-aware (args.repo passed to read_version/latest_tag). CORRECTION 6: lifecycle terminalization — execution exception now guarantees task terminalizes as failed with evidence (no immortal RUNNING). CORRECTION 7: route instruction task-scoped on RuneTask.route_instruction (NOT shared operator state). Read-only leading-verb override. Isolation tests prove no cross-contamination between interleaved tasks. PL1-01/02/03/05: pl1_records.py (route resolution, proof level, failure class, candidate status), metrics with honest NOT_YET_MEASURABLE semantics, route-instruction learning loop (PE-1789102658-0269 SEED). 1253 tests green; ratchet 352/80 (shrink); henxels clean.
132 lines
4.9 KiB
Python
132 lines
4.9 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"}
|