#!/usr/bin/env python3 """Run the repository's unattended jobs, one at a time, in their own worktree. Jobs are declared in automation/jobs.yml: the commands, the gate they must pass, the artifacts computed twice, the paths each commit may carry and the schedule. A run takes one host-wide lock, waits for an idle host, refuses to start on a full disk, and retries a step once on an idle host when it died the way this host's CPU fails (exit 139, SystemError, "unknown opcode"). Jobs that commit run in .cache/autorun/worktree on the branch `autorun`, never in the shared checkout and its index. The worktree is created on the first run, fast-forwarded (or rebased) onto origin/main before each job, and a job stops when it finds anything left uncommitted there. Commits name their paths. Pushing is off unless --push is given: the batch is rebased on origin/main, the gate runs again when origin/main moved, and the push never forces. A red CI run on a pushed automation commit opens a circuit breaker that stops every commit and push until a green run on main follows. State, logs and the worktree live under .cache/autorun/ of the main checkout. The lock is ~/.local/state/retrobios/auto.lock (RETROBIOS_AUTO_LOCK overrides it), shared with any other heavy job on this host. Usage: python scripts/autorun.py run nightly python scripts/autorun.py run nightly --dry-run python scripts/autorun.py run nightly --push python scripts/autorun.py status python scripts/autorun.py status --json python scripts/autorun.py install-timers python scripts/autorun.py install-timers --enable """ from __future__ import annotations import argparse import contextlib import datetime as dt import fnmatch import json import os import re import shlex import shutil import subprocess import sys import time from collections.abc import Callable, Iterator, Sequence from dataclasses import dataclass, field from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] JOBS_FILE = REPO_ROOT / "automation" / "jobs.yml" AUTORUN_DIR = Path(".cache") / "autorun" DEFAULT_LOCK = Path.home() / ".local" / "state" / "retrobios" / "auto.lock" LOCK_ENV = "RETROBIOS_AUTO_LOCK" LOCK_HELD_ENV = "RETROBIOS_AUTO_LOCK_HELD" BRANCH = "autorun" REMOTE = "origin" TARGET = "main" REPO_SLUG = "Abdess/retrobios" OK = "ok" FAILED = "failed" DEFERRED = "deferred" REFUSED = "refused" BLOCKED = "blocked" EXIT_CODES = {OK: 0, DEFERRED: 75} ALERT_FREE_GB = 100 LOG_DAYS = 30 SEGFAULT_CODES = (139, -11) HOST_MISREAD_MARKERS = ("SystemError", "unknown opcode") VOLATILE_KEYS = frozenset({"generated_at", "generated", "imported_at"}) CI_RED = frozenset({"failure", "timed_out", "startup_failure"}) CI_GREEN = frozenset({"success", "skipped", "neutral"}) JOB_KEYS = frozenset({ "schedule", "steps", "gate", "twice", "commits", "worktree", "lock", "network", "min_free_gb", "max_load_wait", "timeout", "scratch", "report", }) class AutorunError(RuntimeError): """A condition that ends a job with a named result.""" def __init__(self, result: str, detail: str) -> None: super().__init__(detail) self.result = result self.detail = detail class LockBusy(RuntimeError): """Another job holds the host-wide lock.""" @dataclass(frozen=True) class Step: argv: tuple[str, ...] ok_codes: tuple[int, ...] = (0,) stdout: str = "" # Variables passed through beside the allowlist (romfetch's settings). env: tuple[str, ...] = () # GITHUB_TOKEN for a first-party script that calls the GitHub API. github_token: bool = False @dataclass(frozen=True) class Twice: """An artifact the gate wrote, computed again into {out} and compared.""" artifact: str argv: tuple[str, ...] glob: str = "" @dataclass(frozen=True) class CommitRule: subject: str paths: tuple[str, ...] body: str = "" @dataclass(frozen=True) class Job: name: str steps: tuple[Step, ...] = () gate: tuple[Step, ...] = () twice: tuple[Twice, ...] = () commits: tuple[CommitRule, ...] = () schedule: str = "" worktree: bool = True lock: bool = True network: bool = False min_free_gb: float = 30.0 max_load_wait: int = 3600 timeout: int = 21600 scratch: tuple[str, ...] = () report: str = "" @dataclass class Outcome: returncode: int text: str = "" retried: bool = False @dataclass class Host: """What a run reads from the machine, replaced by fakes in the tests.""" loadavg: Callable[[], tuple[float, float, float]] = os.getloadavg sleep: Callable[[float], None] = time.sleep clock: Callable[[], float] = time.monotonic cpu_count: int = os.cpu_count() or 1 poll: float = 60.0 @property def max_load(self) -> float: return self.cpu_count / 4 @dataclass class Context: repo: Path jobs: dict[str, Job] lock_path: Path host: Host = field(default_factory=Host) gh: Callable[[list[str]], object] | None = None dry_run: bool = False push: bool = False @property def home(self) -> Path: return self.repo / AUTORUN_DIR @property def worktree(self) -> Path: return self.home / "worktree" @property def state_path(self) -> Path: return self.home / "state.json" # Job registry def check_subject(subject: str, body: str = "") -> list[str]: """What the repository's commit rules refuse in this message, checked when the job list loads instead of two hours into the job.""" from commit_rules import problems # noqa: PLC0415 return problems(f"{subject}\n\n{body}" if body else subject) # The first-party scripts that call the GitHub API and may receive # GITHUB_TOKEN: run as `python