CI: the desktop runner works while its screen is locked or untouched for 15 min

Ludovic's rule (2026-10-26): runner_pause.py pauses at once on keyboard, mouse or gamepad input
(read from /dev/input, only whether bytes came), on an unlocked session touched in the last
15 min, at 90 °C, on a game or the GPU. Fixes the service start: the link loop's last test
failed the script under set -e.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-26 00:27:22 +01:00
1 parent d81aa97e84
commit 0baf6cbd62
6 files changed
+113 -36

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ The game logs into `HORIZON_HOME` and falls back to the player's own game folder
### Other machines
Harnesses can also run on the Mac mini, the ThinkPad and the desktop (`tools/parity_hosts.json` describes them; `desktop` uses its CI runner's environment and vanilla data, and refuses work, or stops it, while someone is at it: docs/release-flow.md, "The desktop's runner"; its `default` list is empty, so pass `--hosts macmini,thinkpad` or set `PARITY_HOSTS`, after copying `shared-local/` to `~/code/horizon-parity/shared-local` on each host). The runner mirrors the worktree there with rsync (not `.git`, `.local`, `target` or ignored files) into `~/code/horizon-parity/remote/<host><path>`, whose `.local` links to the vanilla data in `~/code/horizon-parity/shared-local`, and starts `tools/parity.py --worker` over ssh. The worker builds the probes (about 80 s the first time) and runs what it is sent under the same tracer, admitting harnesses against its own free memory. One queue, longest first, feeds every machine, remote ones first: the Mac takes up to 14 harnesses, the desktop 3 and only those under 800 MB. Each pass comes back with the content hash of every file it depended on, so it is keyed on exactly what ran, and logs come back to `.local/parity-runner/logs/`; state, sharing and `gate.py` work as before. If a host can't be reached, or drops out, its harnesses run here.
Harnesses can also run on the Mac mini, the ThinkPad and the desktop (`tools/parity_hosts.json` describes them; `desktop` uses its CI runner's environment and vanilla data, and refuses work, or stops it, while someone uses it: docs/release-flow.md, "The desktop's runner"; its `default` list is empty, so pass `--hosts macmini,thinkpad` or set `PARITY_HOSTS`, after copying `shared-local/` to `~/code/horizon-parity/shared-local` on each host). The runner mirrors the worktree there with rsync (not `.git`, `.local`, `target` or ignored files) into `~/code/horizon-parity/remote/<host><path>`, whose `.local` links to the vanilla data in `~/code/horizon-parity/shared-local`, and starts `tools/parity.py --worker` over ssh. The worker builds the probes (about 80 s the first time) and runs what it is sent under the same tracer, admitting harnesses against its own free memory. One queue, longest first, feeds every machine, remote ones first: the Mac takes up to 14 harnesses, the desktop 8. Each pass comes back with the content hash of every file it depended on, so it is keyed on exactly what ran, and logs come back to `.local/parity-runner/logs/`; state, sharing and `gate.py` work as before. If a host can't be reached, or drops out, its harnesses run here.
Harnesses a host can't run are listed in the file: `local_only` (`save_roundtrip` runs the game on a virtual screen) and each host's `exclude`. The Mac excludes nothing: vanilla's `Math.sin`, `pow`, `log` and the like don't round the same on an Apple silicon Mac as on Linux (HotSpot uses fdlibm there, Intel's stubs on x86-64), and Rust's float functions round like neither (they call the platform libm), so code that mirrors a vanilla `Math` call uses `horizon_core::jmath`, which computes what HotSpot computes on the host architecture. The ThinkPad helps too, gently: Linux, 2 harnesses at `nice 19`, since it hosts the Claude account pool; it finds ALSA through `~/code/horizon-parity/alsa-dev` like the desktop.
+17 -15
View File
@@ -68,21 +68,23 @@ release's macOS build back. Each keeps its own checkout, `target/` and Rust inst
time, under a lock) and the parity passes and slots.
**The desktop's runner.** `ludodesktop-horizon` (labels `self-hosted`, `Linux`, `X64`, `ludodesktop`, and
`horizon-game` once a full trial has passed there) runs on the maintainer's Linux desktop, only while nobody is
at it. `tools/ci/linux-runner.sh install`, run there as him, registers it and installs two systemd user services:
`horizon-runner` (the runner, in `~/.actions-runners/horizon`, nice 19, in `horizon-ci.slice` with CPU and I/O
weight 20 and memory capped at two thirds of the RAM; `CARGO_BUILD_JOBS` and the harness slots at 8, after the
machine froze under ~7 cold cargo builds on 2026-10-14; its own Rust install; the JDK, sccache and the build
libraries from nixpkgs; its own `vscreen` so no game it starts reaches his screen) and `horizon-runner-pause`
(`tools/ci/runner_pause.py`), the only thing that starts the runner. The watcher stops it while a graphical
session is open on the machine, locked or not (`~/fleet/power status` says "IN USE"), while a game runs (Steam,
gamescope, Wine, Proton or Minecraft processes, fleet-load's own detection, or a process outside the CI slice
using the GPU at 50 % or more), or while `~/.actions-runners/horizon/pause` exists, and starts it again 5 minutes
after the last such sign. A job already under way is throttled to 2 threads at the lowest weight and the runner
stops after it: freezing the job would make GitHub drop it as lost and the harnesses time out. Old pull requests'
`runner-env.sh` know only the Mac: the runner's `.env` and `.path` give their jobs Python, the JDK and the
vanilla data (`HORIZON_SHARED_SOURCE`) all the same. The nightly audit stays on the Mac mini's runners, so its
comparison with the previous night is always made on one platform.
`horizon-game` once a full trial has passed there) runs on the maintainer's Linux desktop while nobody uses it: its
screen locked, or untouched for 15 minutes (his rule, 2026-10-26). `tools/ci/linux-runner.sh install`, run there as
him, registers it and installs two systemd user services: `horizon-runner` (the runner, in
`~/.actions-runners/horizon`, nice 19, in `horizon-ci.slice` with CPU and I/O weight 20 and memory capped at two
thirds of the RAM; `CARGO_BUILD_JOBS` and the harness slots at 8, after the machine froze under ~7 cold cargo
builds on 2026-10-14; its own Rust install; the JDK, sccache and the build libraries from nixpkgs; its own
`vscreen` so no game it starts reaches his screen) and `horizon-runner-pause` (`tools/ci/runner_pause.py`), the
only thing that starts the runner. The watcher stops it at once on keyboard, mouse or gamepad input (read from
`/dev/input`: only whether bytes came, never what), while an unlocked session has had input in the last 15 minutes,
while the CPU package is at 90 °C or more, while a game runs (Steam, gamescope, Wine, Proton or Minecraft
processes, fleet-load's own detection, or a process outside the CI slice using the GPU at 50 % or more), or while
`~/.actions-runners/horizon/pause` exists, and starts it again 5 minutes after the last such sign. A job already
under way is throttled to 2 threads at the lowest weight and the runner stops after it: freezing the job would make
GitHub drop it as lost and the harnesses time out. Old pull requests' `runner-env.sh` know only the Mac: the
runner's `.env` and `.path` give their jobs Python, the JDK and the vanilla data (`HORIZON_SHARED_SOURCE`) all the
same. The nightly audit stays on the Mac mini's runners, so its comparison with the previous night is always made
on one platform.
## Merging
+12 -1
View File
@@ -18,7 +18,7 @@ class Machine:
self.throttles = [] # (clock, on)
self.calls = []
def reason(self, hot):
def reason(self, hot, keys):
return next((reason for start, end, reason in self.signs if start <= self.clock < end), None)
def systemctl(self, *arguments):
@@ -77,6 +77,17 @@ class RunnerPauseTests(unittest.TestCase):
self.assertLess(stop, 1215)
self.assertEqual(machine.throttles[-1], (1290 + 300, False))
def test_the_desktop_is_free_when_locked_or_untouched_for_15_minutes(self):
cases = [ # (session, seconds since the last input, someone there)
(("3", False), 30, True), (("3", True), 30, True), # any input pauses at once, even on the lock screen
(("3", False), 120, True), (("3", False), 1000, False), # unlocked: free after 15 min untouched
(("3", True), 120, False), (None, 120, False), # locked, or nobody logged in
]
for session, idle, there in cases:
with self.subTest(session=session, idle=idle), \
mock.patch.object(runner_pause, "seat_session", lambda: session):
self.assertEqual(runner_pause.at_the_screen(idle) is not None, there)
if __name__ == "__main__":
unittest.main()
+6 -6
View File
@@ -9,10 +9,10 @@
#
# Everything lives in ~/.actions-runners/horizon (never in ~/code): the runner, its own rustup and cargo homes,
# the work folder, and copies of this script and runner_pause.py taken at install time (a pull request's checkout
# never runs as the service). horizon-runner-pause.service (runner_pause.py) starts the runner only while nobody is
# at the screen or playing, and stops it otherwise. The runner runs nice, in horizon-ci.slice (low CPU and I/O weight, memory capped),
# on its own virtual screen: vscreen (a headless sway) where it is installed, else Xvfb from nixpkgs with Mesa's
# Vulkan drivers.
# never runs as the service). horizon-runner-pause.service (runner_pause.py) starts the runner only while nobody
# uses the machine (screen locked or untouched for 15 min, no game), and stops it otherwise. The runner runs nice,
# in horizon-ci.slice (low CPU and I/O weight, memory capped), on its own virtual screen: vscreen (a headless sway)
# where it is installed, else Xvfb from nixpkgs with Mesa's Vulkan drivers.
# HORIZON_SHARED_SOURCE: the imported vanilla data (default ~/horizon-local/shared-local, else
# ~/code/horizon/.local); RUNNER_JOBS: cargo jobs and harnesses at once (default a third of the threads, at most 8:
# ludodesktop froze under ~7 cold cargo builds on 2026-10-14, and it is the maintainer's own machine).
@@ -40,7 +40,7 @@ parity_host_links() {
[ -d "$checkout" ] || return 0
mkdir -p "$shared"
for entry in "$checkout"/*; do
[ -L "$entry" ] && ln -sfn "$entry" "$shared/$(basename "$entry")"
if [ -L "$entry" ]; then ln -sfn "$entry" "$shared/$(basename "$entry")"; fi
done
}
@@ -151,7 +151,7 @@ RestartSec=30
EOF
cat > "$units/horizon-runner-pause.service" <<EOF
[Unit]
Description=Runs the Horizon CI runner only while nobody is at the screen or playing
Description=Runs the Horizon CI runner only while nobody uses the machine or plays on it
[Service]
ExecStart=$(command -v python3) $dir/runner_pause.py
Restart=always
+76 -12
View File
@@ -1,9 +1,11 @@
#!/usr/bin/env python3
"""Pauses the Horizon CI runner (tools/ci/linux-runner.sh) while the maintainer is at this machine or plays on it.
"""Pauses the Horizon CI runner (tools/ci/linux-runner.sh) while the maintainer uses this machine or plays on it.
The runner works only while nobody is at the screen (rule of 2026-10-25, after ludodesktop was restarted under a
CI load while he used it). It pauses when any of these holds:
- a graphical session is open on seat0, locked or not: what `~/fleet/power status` calls "IN USE";
The runner works while nobody uses the machine: its screen locked, or untouched for IDLE_AFTER seconds (900).
It pauses when any of these holds:
- keyboard, mouse or gamepad input in the last INPUT_PAUSE seconds (60), or an unlocked session on seat0 with
input in the last IDLE_AFTER seconds;
- the CPU package at TEMP_LIMIT °C (90) or more;
- fleet-load says a game runs (~/fleet/load: Steam, gamescope, Wine or Proton processes);
- a process of his matches GAME below (the same, plus Minecraft and a Horizon game he started himself);
- a process outside horizon-ci.slice keeps the GPU busy (nvidia-smi: SM use >= GPU_BUSY %, default 50, in two
@@ -34,6 +36,9 @@ GPU_BUSY = int(os.environ.get("GPU_BUSY", 50))
INTERVAL = int(os.environ.get("INTERVAL", 15))
RESUME_AFTER = int(os.environ.get("RESUME_AFTER", 300))
THROTTLE_CPUS = int(os.environ.get("THROTTLE_CPUS", 2))
INPUT_PAUSE = int(os.environ.get("INPUT_PAUSE", 60))
IDLE_AFTER = int(os.environ.get("IDLE_AFTER", 900))
TEMP_LIMIT = int(os.environ.get("TEMP_LIMIT", 90))
def log(text):
@@ -53,13 +58,71 @@ def ci_process(pid):
return True # gone
def at_the_screen():
"""A graphical login on seat0, as ~/fleet/power status counts it (a locked screen counts too)."""
class Input:
"""Keyboard, mouse and gamepad activity, from /dev/input (the input group): only whether bytes came, never what."""
def __init__(self):
self.devices = {} # path -> file descriptor
self.last = time.monotonic() # a start counts as input: nothing is known yet
def poll(self):
try:
text = Path("/proc/bus/input/devices").read_text()
except OSError:
text = ""
wanted = {f"/dev/input/{handler}" for block in text.split("\n\n") for line in block.splitlines()
if line.startswith("H: Handlers=") and re.search(r"\b(kbd|mouse\d*|js\d*)\b", line)
for handler in line.split("=", 1)[1].split() if handler.startswith("event")}
for path in set(self.devices) - wanted:
os.close(self.devices.pop(path))
for path in wanted - set(self.devices):
try:
self.devices[path] = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
except OSError:
pass
for path, descriptor in list(self.devices.items()):
try:
while os.read(descriptor, 4096):
self.last = time.monotonic()
except BlockingIOError:
pass
except OSError: # unplugged
os.close(self.devices.pop(path))
return time.monotonic() - self.last
def seat_session():
"""The graphical session on seat0 and whether its screen is locked, or None."""
sessions = subprocess.run(["loginctl", "list-sessions", "--no-legend"], capture_output=True, text=True).stdout
for line in sessions.splitlines():
fields = line.split()
if "seat0" in fields:
return f"session {fields[0]} of {fields[2] if len(fields) > 2 else '?'} at the screen (seat0)"
locked = subprocess.run(["loginctl", "show-session", fields[0], "-p", "LockedHint", "--value"],
capture_output=True, text=True).stdout.strip() == "yes"
return fields[0], locked
return None
def at_the_screen(input_idle):
"""Someone is at the machine: input in the last INPUT_PAUSE seconds, or an unlocked session with input in the last
IDLE_AFTER seconds. A locked screen, or one nobody touched for IDLE_AFTER, leaves the machine to CI."""
if input_idle < INPUT_PAUSE:
return f"input {input_idle:.0f} s ago"
session = seat_session()
if session and not session[1] and input_idle < IDLE_AFTER:
return f"session {session[0]} unlocked, input {input_idle:.0f} s ago"
return None
def too_hot():
"""The CPU package above TEMP_LIMIT °C (coretemp or k10temp)."""
for monitor in Path("/sys/class/hwmon").glob("hwmon*"):
try:
if (monitor / "name").read_text().strip() in ("coretemp", "k10temp"):
celsius = int((monitor / "temp1_input").read_text()) / 1000
return f"CPU at {celsius:.0f} °C" if celsius >= TEMP_LIMIT else None
except (OSError, ValueError):
pass
return None
@@ -133,11 +196,12 @@ def throttle(on):
systemctl("set-property", "--runtime", SLICE, "CPUWeight=20", "IOWeight=20", "CPUQuota=")
def pause_reason(hot):
"""Why the runner may not work now, or None. hot: the GPU users seen busy at the last sample (updated)."""
def pause_reason(hot, keys):
"""Why the runner may not work now, or None. hot: the GPU users seen busy at the last sample (updated);
keys: the Input watcher."""
if MANUAL.exists():
return f"paused by hand ({MANUAL})"
reason = at_the_screen() or fleet_load_gaming() or game_process()
reason = at_the_screen(keys.poll()) or too_hot() or fleet_load_gaming() or game_process()
if reason:
return reason
busy = {pid for pid, sm in gpu_users().items() if sm >= GPU_BUSY}
@@ -154,13 +218,13 @@ def pause_reason(hot):
def main():
hot = {}
hot, keys = {}, Input()
# A boot (or a restart of this watcher) counts as a sign: the runner starts RESUME_AFTER seconds later at the
# earliest, which leaves time to log in after switching the machine on.
last_sign = time.monotonic()
paused = None
while True:
reason = pause_reason(hot)
reason = pause_reason(hot, keys)
now = time.monotonic()
if reason:
last_sign = now
+1 -1
View File
@@ -18,7 +18,7 @@
"setup": "test ! -e ~/.actions-runners/horizon/paused && set -a && . ~/.actions-runners/horizon/.env && set +a && export PATH=$(cat ~/.actions-runners/horizon/.path) && eval \"$(vscreen env)\"",
"jobs": 8,
"nice": 19,
"note": "ludodesktop, the maintainer's own machine: only while nobody is at the screen (tools/ci/runner_pause.py: a paused desktop refuses workers and stops the running ones, whose harnesses then run here); 8 jobs at most, in the CI runner's environment and slice"
"note": "ludodesktop, the maintainer's own machine: only while nobody uses it (tools/ci/runner_pause.py: screen locked or untouched for 15 min, no game; a paused desktop refuses workers and stops the running ones, whose harnesses then run here); 8 jobs at most, in the CI runner's environment and slice"
},
"thinkpad": {
"ssh": "thinkpad",