Self-hosted CI runners on the Strix Halo box#
Two runners serve this repository, both on Strix Halo, both installed by
scripts/install-ci-runner.sh:
| runner | labels | takes |
|---|---|---|
strixhalo-engine |
self-hosted,Linux,X64,pr-agent,strixhalo |
pr-agent, issue-agent |
strixhalo-bench |
self-hosted,Linux,X64,strixhalo,bench |
hrx-build and the bench/gate jobs |
Everything the GitHub-hosted jobs need comes from ubuntu-* runners; these two exist because those jobs need
this box — its GPU, its NPU and the local LLM at 127.0.0.1:8090.
Why this page exists (engine #1504)#
On 2026-11-08 both runners were OOM-killed at 08:37 and stayed dead until 12:23:
$ systemctl show actions.runner.1bit-MONSTER-engine.strixhalo-bench.service -p Result -p NRestarts
Result=oom-kill
NRestarts=0
NRestarts=0 is the whole bug. The units shipped with no restart policy, and runsvc.sh exits 0 after the
listener stops ("Runner listener exit with 0 return code, stop the service, no retry needed"), so even
Restart=on-failure would never have fired. While they were down, 9 hrx-build and 15 pr-agent jobs queued,
and every check that needs the box read as pending — never green, never red, which looks exactly like "queued
behind a busy runner". It had happened before (2026-11-06, 41 pr-agent runs queued behind the same unit).
A check whose runner is gone is the same silent-absence class as engine #1077; a restart policy is what turns a four-hour outage into a thirty-second one.
The unit, and why each line is there#
scripts/install-ci-runner.sh writes this (values shown for the defaults):
[Unit]
Description=GitHub Actions runner strixhalo-engine (dedicated user ghrunner)
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=0
[Service]
Type=simple
User=ghrunner
WorkingDirectory=/var/lib/ghrunner/actions-runner-strixhalo-engine
ExecStart=/var/lib/ghrunner/actions-runner-strixhalo-engine/runsvc.sh
Restart=always
RestartSec=30
OOMPolicy=continue
MemoryHigh=43G
MemoryMax=48G
TasksMax=8192
KillMode=process
KillSignal=SIGTERM
TimeoutStopSec=5min
[Install]
WantedBy=multi-user.target
Restart=always, noton-failure— becauserunsvc.shreports success when it is killed.alwaysis the only value that brings a runner back from that exit code.RestartSec=30keeps a crash-looping runner from hammering the box and the GitHub API.StartLimitIntervalSec=0— a runner must never be rate-limited out of existence. The 30-second spacing is the rate limit; systemd giving up after five starts in ten seconds (the default) is how "stays dead" happens again.OOMPolicy=continue— this is the line the old units lacked. With the default (stop), a process in the cgroup being OOM-killed stops the unit withResult=oom-kill. Withcontinue, the kernel's victim is the one that dies — the job — and the runner keeps running, so the job fails with a reason instead of the runner vanishing.MemoryHigh/MemoryMax— the cgroup hits its own ceiling before the box's OOM killer has to choose between "the largest allocation on the machine" and everything else. The default 48G matches the bench rule (systemd-run --user --scope -p MemoryMax=48G), so a heavy job is killed by its limit, not by the box's.KillMode=processwith the 5-minute stop timeout — the behaviour the shipped units already had: stop the listener, let the job's own timeout end its children.
The dedicated user#
The jobs used to run as the owner (User=bcloud). They now run as ghrunner: no shell, home
/var/lib/ghrunner, and the runner's _work tree under it. Anything a job writes into $HOME lands there, not
in the owner's tree, and the account holds no ssh keys and no fleet git remotes of its own.
It is not read-isolation of the owner's files, though — /home/bcloud is drwxrwxr-x on this box, so a job
as ghrunner can read whatever in the owner's home is world-readable. The isolation is per-process and
per-account (a job cannot use the owner's keys to push, and its $HOME writes are its own); if read-isolation is
wanted too, that is chmod o-rx /home/bcloud — the owner's call, because it changes the box for every other lane
as well.
One queue, two accounts#
The workflows reach the box's hold state through $HOME: ~/GPU-TOKEN, ~/GPU-QUEUE (and npu-hold, which
hard-codes ~/NPU-TOKEN and ~/NPU-QUEUE around gpu-hold). A runner with a different home therefore does not
merely lose thermal-run.sh — it takes its own GPU token, and the hardware gate then runs GPU work
concurrently with whatever lane holds the real one, which is the one thing the hold rule exists to prevent.
The obvious repair — put the runner's account in the owner's group — is wrong, and the review of this PR measured
why: /home/bcloud is 0775, so group write on the directory lets a job rename or replace any entry in it.
A job could rewrite ~/.local/bin/gpu-hold (which every lane then executes), replace ~/.bashrc, or move
~/.ssh aside and put its own directory in its place. That is an owner-level code path created by a recipe whose
whole point is that jobs no longer run as the owner.
So the hold state lives in its own directory, owned by its own group:
/var/lib/1bit-fleet/ 0770 fleethold, setgid
gpu-token 0660 fleethold <- what gpu-hold writes, and rm -f's on release
gpu-queue/ 0770 fleethold, setgid <- tickets inherit the group
npu-token, npu-queue/
ghrunnerjoinsfleetholdand nothing else. The shared tools (~/lb,~/.local/bin) are already world-readable and executable, so no group is needed to run them — only the hold files need the group.- The unit gets
Environment=GPU_TOKEN_FILE=/var/lib/1bit-fleet/gpu-token GPU_QUEUE_DIR=/var/lib/1bit-fleet/gpu-queue, whichgpu-holdhonours, so a job queues with the fleet. - The owner's side has to name the same two paths, or the split simply reappears in the other direction. The
installer writes
/etc/profile.d/1bit-fleet.shexporting them for the owner's shells; a long-running owner service needs them in its own unit or environment. - Why a shared file inside the owner's home cannot work at all:
gpu-holdreleases withrm -f "$TOKEN", a write to the containing directory.chgrpon the file is not enough, and a symlink is worse —rmremoves the link and leaves the real token held for ever. npu-holdwrapsgpu-holdwithexec env GPU_QUEUE_DIR=~/NPU-QUEUE GPU_TOKEN_FILE=~/NPU-TOKEN …, so it overrides whatever the environment says: NPU sharing needs that wrapper changed to honour the two variables. Until then the NPU queue is per-account, and that is a named follow-up rather than a claim.
The tools are reached by a home-relative path#
hrx-build.yml:111,132 and hw-gate.yml call ~/lb/thermal-run.sh, and hw-gate.sh resolves gpu-hold with
command -v — a home-relative path, not a permission. So the installer links ~/lb and ~/.local/bin from the
runner's home into the account that owns them and puts .local/bin first on the unit's PATH. The runner reaches
both through other (r-x), so it can run the tools and cannot rewrite them — which is the difference between
this and the owner's group, and the reason the links are back after the review's first round.
thermal-run.sh runs its jobs under systemctl --user, so the installer also does
loginctl enable-linger <user>; without a user manager for that account it falls back to a process-group
SIGSTOP, which is the weaker thermal brake the script itself warns about.
hw-gate.sh has two more $HOME defaults (:98-99), and both resolve into an empty home for a runner under
another account:
| variable | default | the installer sets it to |
|---|---|---|
HW_GATE_MODELS |
$HOME/models |
--owner-user's models/ when it exists — read-only |
HW_GATE_LOCK |
$HOME/npu.lock |
<hold-state>/npu.lock — the fleet's lock, so a runner cannot take NPU work on a lock of its own (the NPU version of the GPU-token bypass) |
The same reasoning is applied at the other end: scripts/hw-gate.sh now prefers <hold-state>/npu.lock when that
directory exists, and scripts/gpu-hold prefers <hold-state>/{gpu-token,gpu-queue}, so the default is what
moved, not an environment variable. That matters because the lanes are long-lived tmux/pi sessions and
systemd --user services, none of them login shells: /etc/profile.d reaches none of them, and a caller without
the variables would otherwise fall back to ~/GPU-TOKEN and silently fork the queue. ONEBIT_HOLD_DIR overrides
the directory if it ever lives elsewhere.
One consequence worth writing down, found by tests/gpu_hold_queue_test.sh: gpu-hold treats the token file
existing as "held" — the holder creates it with noclobber — so the installer must not pre-create an empty
gpu-token. It creates the directory and the queues, and lets the first claim create the token.
--no-hold-stateexists for a runner that runs no hardware jobs — thepr-agentrunner does not (it talks to the local LLM over HTTP on127.0.0.1:8090), so it needs none of this.--migrate-hold-statemoves an existing~/GPU-{TOKEN,QUEUE},~/NPU-{TOKEN,QUEUE}into/var/lib/1bit-fleet, and refuses when the queue is not empty: moving a live queue out from under the lanes is not a step to take blind.
What is deliberately not shared is ~/.cache, which is 0700 on the owner's account: the persistent build
trees (~/.cache/hrx-build/bench, ~/.cache/hw-gate) and the pr-agent-venv-* environments move, so the first
runs on a fresh install are cold — hrx-build's "rebuild only what changed" starts from zero once.
What it still needs, and the honest price of each:
| grant | why | price |
|---|---|---|
docker group |
the bench jobs run containers (podman/docker images for the distro proofs) | docker group is root-equivalent — a job that runs docker run -v /:/h … can read the box. It is narrower than the owner's account but not a sandbox |
render (or video) group |
/dev/kfd and /dev/dri/renderD* at 0660 for the GPU jobs |
device access only |
fleethold (its own group) |
write the hold state in /var/lib/1bit-fleet |
the runner can write the queue and the token files and nothing else — not the owner's home, not the tools it runs |
If that trade is not wanted, the next step is rootless podman (or a docker socket proxy that only allows the
images the workflows name) and dropping the docker group — that is a change to the workflows, not to this
recipe, and it is not in #1504's scope.
A fresh install, start to finish#
# 1. the runner, unpacked and verified. Pin the version; the script checks the release's SHA256SUMS and
# refuses an unverified tarball.
curl -fsSLO https://github.com/actions/runner/releases/download/v2.329.0/actions-runner-linux-x64-2.329.0.tar.gz
mkdir -p /srv/actions-runner-2.329.0 && tar -C /srv/actions-runner-2.329.0 -xzf actions-runner-linux-x64-2.329.0.tar.gz
# 2. a registration token: repo Settings -> Actions -> Runners -> New self-hosted runner, or
# gh api -X POST repos/1bit-MONSTER/engine/actions/runners/registration-token --jq .token > /root/runner-token
# It is a one-hour secret: write it to a root-only file and delete it after the install.
# 3. install both runners
sudo scripts/install-ci-runner.sh --name strixhalo-engine --labels pr-agent \
--token-file /root/runner-token --runner-dir /srv/actions-runner-2.329.0
sudo scripts/install-ci-runner.sh --name strixhalo-bench --labels bench \
--token-file /root/runner-token --runner-dir /srv/actions-runner-2.329.0
sudo rm -f /root/runner-token
# 4. the owner's own lanes must name the same hold state (the installer writes this file); a long-running
# owner service needs the two variables in its own unit instead
. /etc/profile.d/1bit-fleet.sh
Order matters, and the installer enforces it. The default that keeps the queue un-forked lives in
scripts/gpu-hold; what runs on the box is ~/.local/bin/gpu-hold, which every lane and the runner reach
through their own home. So the installer puts the repository's copy in place (keeping a dated .bak-*, as this
box does for every other tool) before anything else, and --migrate-hold-state refuses to run against a copy
that still defaults to ~/GPU-TOKEN — otherwise the migration moves the token and the first lane without
GPU_TOKEN_FILE recreates the old path, which is two queues again.
On a box that already has hold state in the owner's home, the first install takes
--migrate-hold-state --owner-user <name>, with the queue drained — the installer refuses a non-empty queue.
NPU is not shared yet, and the installer says so at install time. npu-hold is not in this repository, and it
pins the queue with exec env GPU_QUEUE_DIR="$HOME/NPU-QUEUE" GPU_TOKEN_FILE="$HOME/NPU-TOKEN" …
(~/.local/bin/npu-hold:31), which resolves to a private NPU queue for any other account whatever gpu-hold's
default is. The unit sets HW_GATE_LOCK to the fleet's lock, so the lock is shared; the queue is not. Until
npu-hold honours the two variables, a runner under another account must not take NPU work.
--dry-run prints the unit and runs systemd-analyze verify on it without changing anything; --verify prints
the installed unit's Result/NRestarts/OOMPolicy and exits. The script is idempotent: an existing ghrunner
user, an already-configured runner directory and an existing unit are reused or rewritten, not re-created.
Verifying an install#
# the unit is up and has not been restarting
systemctl show actions.runner.1bit-MONSTER-engine.strixhalo-engine.service \
-p ActiveState -p Result -p NRestarts -p OOMPolicy -p MemoryMax
# ActiveState=active Result=success NRestarts=0 OOMPolicy=continue MemoryMax=51539607552
# GitHub agrees it is online and idle
gh api repos/1bit-MONSTER/engine/actions/runners --jq '.runners[] | "\(.name) \(.status) busy=\(.busy)"'
# strixhalo-bench online busy=false
# strixhalo-engine online busy=false
And the OOM behaviour itself, which is what #1504 is about: with OOMPolicy=continue, a process killed inside the
unit's cgroup does not stop the unit (systemd.service(5): "the service manager will not take any action"), so
the job fails and the runner keeps listening; with the default stop it ends up exactly as it did on 11-08 —
Result=oom-kill, NRestarts=0.
# the state to read after a heavy job has been killed, on a freshly installed runner:
systemctl show actions.runner.1bit-MONSTER-engine.strixhalo-bench.service -p Result -p NRestarts
# Result=success <- the job died, the runner did not
# NRestarts=0 <- nothing had to restart it
Not yet demonstrated end to end here. A throwaway systemd-run --user pair with and without
OOMPolicy=continue (128M cap, a child allocating 256M) both came back Result=success on this box — the child's
allocation failed as an exception rather than as an OOM kill, so that experiment says nothing either way. The
directive is the documented mechanism and it is what the fresh install's single end-to-end run should confirm,
together with the rest of #1504's acceptance.
What a restart policy cannot do#
It cannot notice that a runner is not coming back (a version mismatch, a revoked token, a full disk under
/var/lib/ghrunner). That is engine #1504's item 3, and it is
scripts/check-ci-runners.sh:
scripts/check-ci-runners.sh --list # what the API says, and the verdict
scripts/check-ci-runners.sh --require strixhalo-engine strixhalo-bench # the fleet, named
The sentence to keep in mind while reading any check list: every check that needs this box reads as pending, never red. That is why the 2026-11-08 outage lasted 3h45m instead of being visible in a minute — a job whose runner does not exist never runs, so GitHub never fails it, and a pending check looks exactly like a busy queue. That is the person this row is for: whoever is staring at a green-looking list.
It exits 0 when both named runners are online, 1 with a ::error:: line per offender, and 2 when the list
cannot be read at all — an unchecked runner is exactly the state the row exists to end. It is also 2 when the list
is truncated (one page is 100 runners): past that, a runner that exists on page two would be reported missing,
which is a false outage, and a monitor that cannot tell has to say so rather than answer wrongly. --require is not decoration: a
runner that has been deleted satisfies "every registered runner is online", so the two names have to be asked for
explicitly. A repository with no self-hosted runners registered is not an outage, so a fresh box and a fork both
exit 0.
scripts/weekly.sh runs it in the preflight, as a fatal row: a release whose post-release checks queue for ever is
worse than a release that waits an hour, and WEEKLY_SKIP_RUNNER_CHECK=1 is the deliberate way to publish during an
outage. tests/check_ci_runners_test.sh (ctest check_ci_runners) covers the arms with a fake gh: both online,
one offline, an unrequired dead runner, a required runner that is not registered, nothing registered, an API failure,
and --list.
It cannot be a ctest itself — it needs the network and a token, which the GitHub-hosted job does not have. A scheduled red (a cron workflow that fails once an hour while a runner is down) is the complete answer and is not here: a new workflow changes what the repository's token may do, which is an RFC under CONTRIBUTING.md.
Two rows, one question: are the runners healthy?#
Since #1682 and #1546 both landed there are two checks, and neither alone is enough — each is blind to what the
other sees. Both run in scripts/weekly.sh's preflight. A runner that is not accepting jobs stops the release
before it starts. A runner that is up but whose restart policy is short is a warning, and the release goes on
(owner decision on engine#1504, 2026-11-11): see the next section.
scripts/check-ci-runners.sh |
scripts/ci-runner-health.sh |
|
|---|---|---|
| reads | the GitHub API (…/actions/runners) |
systemd (systemctl show) |
| says | is it registered and online |
is the unit active, Result=, NRestarts |
| notices | a registration that was revoked, expired or never made | Result=oom-kill, a restart window, a unit that stopped |
| cannot see | why it died — no oom-kill, no restart count |
a unit that is running fine while its registration is gone |
| speed | after the heartbeat times out | the moment the unit dies |
| exit | 0 online · 1 an offender, named · 2 cannot tell (unreadable or truncated list) | 0 all accepting, policy right · 1 one is not accepting jobs · 2 no such units on this host · 3 all accepting, a restart policy is short |
Run either by hand — both are read-only and neither restarts anything:
scripts/check-ci-runners.sh --require strixhalo-engine strixhalo-bench --list
scripts/ci-runner-health.sh # every actions.runner.* unit
scripts/ci-runner-health.sh --self-test # the verdict logic, systemctl stubbed
WEEKLY_SKIP_RUNNER_CHECK=1 releases during an outage, and it skips both rows: they fail for the same dead
runner, so a flag that skipped only one of them could not do the job it documents. It also says so in the log --
preflight: runner checks SKIPPED (WEEKLY_SKIP_RUNNER_CHECK=1) -- because a release log where the checks quietly did
not run reads exactly like one where they passed. Their exit 2 means "nothing to
inspect on this host" — a fresh box or a fork — which is a note, not a failure; a check that cannot see has to say so
rather than report green.
One case neither row covers: a runner that is online in the API and active locally, but wedged so it takes no
jobs. That is a hung process, not a registration or unit state; the practical signal for it is a queue that stops
moving while both rows stay green.
A short restart policy warns, it does not block#
scripts/ci-runner-health.sh prints two lines per unit: its state (PASS/FAIL) and its restart policy
(PASS/WARN). The exit code keeps them apart, because they call for different things:
| exit | meaning | scripts/weekly.sh preflight |
|---|---|---|
| 0 | every unit is accepting jobs and would come back after an OOM kill | passes |
| 1 | a unit is not accepting jobs now (dead, or in its restart window) | blocks: the checks after the release would queue for ever |
| 2 | no actions.runner.* units on this host |
a note |
| 3 | every unit is accepting jobs, but a WARN line names a policy setting |
warns and goes on |
A unit that is down outranks a policy line, so 1 wins over 3. The warning in the release log reads
preflight: WARNING, not blocking: every runner unit is accepting jobs, but a restart policy would leave one down
after the next OOM kill, with the WARN lines above it naming the unit and the setting.
Why a warning is enough: the policy is a box configuration change, which the owner applies (below), and a release
that cannot start does not make the unit any safer. Meanwhile the heavy builds run under
scripts/mem-guard.sh --term, which ends the build before the kernel's OOM killer reaches the runner. A build step
that ends with exit 143 was stopped by that guard: the log has a GUARD: MemAvailable N GB < floor GB line, the
runner is still alive, and the job is re-run once memory frees. The guard covers only the steps that use it. A server
or a bench started without it can still take the unit down, which is why the drop-ins below are the real fix.
tests/weekly_runner_preflight_test.sh (ctest weekly_runner_preflight) runs the real health script and the real
preflight_runner_units() from scripts/weekly.sh against a stub systemctl: the four exit codes, block on a dead
unit, warn on a short policy.
The two drop-ins (owner step)#
On strixhalo, strixhalo-bench and strixhalo-org still run the units that predate scripts/install-ci-runner.sh:
Restart=no, OOMPolicy=stop. One OOM kill of any process in the unit stops it for good. strixhalo-engine got
the same drop-in on 2026-11-10. This needs sudo, so it is the owner's to run. It does not re-register a runner,
change its user, or stop a job in flight.
Both files have the same content, scripts/ci-runner-restart.conf:
# /etc/systemd/system/actions.runner.1bit-MONSTER-engine.strixhalo-bench.service.d/restart.conf
# /etc/systemd/system/actions.runner.1bit-MONSTER.strixhalo-org.service.d/restart.conf
[Unit]
StartLimitIntervalSec=0
[Service]
Restart=always
RestartSec=15
OOMPolicy=continue
The three commands, from the root of an engine checkout:
sudo install -Dm644 scripts/ci-runner-restart.conf /etc/systemd/system/actions.runner.1bit-MONSTER-engine.strixhalo-bench.service.d/restart.conf
sudo install -Dm644 scripts/ci-runner-restart.conf /etc/systemd/system/actions.runner.1bit-MONSTER.strixhalo-org.service.d/restart.conf
sudo systemctl daemon-reload
Check it took (read-only, no sudo):
systemctl show actions.runner.1bit-MONSTER-engine.strixhalo-bench.service actions.runner.1bit-MONSTER.strixhalo-org.service -p Id -p Restart -p OOMPolicy
scripts/ci-runner-health.sh; echo "exit $?"
Want Restart=always and OOMPolicy=continue on both. The health script still exits 3 afterwards, with one WARN
per unit naming only MemoryMax=infinity(want a ceiling): the drop-in sets no memory ceiling. The ceiling this page
documents (MemoryHigh=43G, MemoryMax=48G) comes with a full scripts/install-ci-runner.sh install, or with
sudo systemctl set-property <unit> MemoryHigh=43G MemoryMax=48G per unit. To undo a drop-in, delete the
restart.conf and run sudo systemctl daemon-reload.