Lightweight, cooperative GPU allocation for one machine shared by a few people, scripts and AI agents, plus advisory RAM/disk limits that tasks can follow.
resourced: a small daemon (standard library only, Linux, Python 3.13).gpuctl: the command line tool. A Python client (resourced.Client) speaks the same protocol.
It is cooperative: nothing stops a program that ignores it. gpuctl verify and the checks at claim and
release time make such programs visible. It is meant for a single user or a few users who can talk to each
other when something goes wrong, not for an enterprise setup.
pipx install git+https://github.com/hansbuehler/resourced # or: uv tool install git+https://github.com/hansbuehler/resourced
gpuctl init # reads nvidia-smi, writes ~/.config/resourced/config.toml
gpuctl service enable --user # install the systemd unit, start at boot, start now
gpuctl statusNeeds Python 3.13 and nvidia-smi. Both pipx and uv tool put gpuctl in ~/.local/bin; if the shell
cannot find it, run pipx ensurepath or uv tool update-shell and open a new shell. Coding agents that run
commands in a sandbox need one extra setting, see Sandboxed coding agents.
gpuctl service enable --dry-run shows the unit and commands without
doing anything. gpuctl service disable stops the autostart.
On a machine used by coding agents, finish initialization by merging
the host guardrails and the output of gpuctl agent-instructions into the agent's
user-level instructions. See Telling agents about it for the destinations and the
project-level template.
Per-user installs need lingering so the service keeps running after you log out
(sudo loginctl enable-linger $USER); gpuctl service enable --user tells you when it is off.
Update with pipx upgrade resourced (or uv tool upgrade resourced) and restart the service
(systemctl --user restart resourced); claims survive a restart.
| Mode | Runtime directory | Socket | Use for |
|---|---|---|---|
| Per-user (default) | $XDG_RUNTIME_DIR/resourced/ |
private (0600) | one person |
| System-wide | /run/resourced/ |
group (0660) via socket_group |
several users |
There must be exactly one daemon per set of GPUs. Several users on one machine share one system install; separate per-user daemons would not see each other's claims and could hand out the same GPU twice.
The service runs as a dedicated resourced account, which cannot read home directories. Install the package
in a location every user can read, such as a virtualenv under /opt (not pipx in your home directory), and run
every gpuctl command below from that environment: the unit records the Python that runs service enable.
# 1. Install where the 'resourced' account can read it (needs Python 3.13)
sudo python3.13 -m venv /opt/resourced
sudo /opt/resourced/bin/pip install git+https://github.com/hansbuehler/resourced
sudo ln -s /opt/resourced/bin/gpuctl /usr/local/bin/gpuctl # optional: gpuctl for all users
# 2. Write /etc/resourced/config.toml from nvidia-smi
sudo /opt/resourced/bin/gpuctl init --system
# 3. Create the access group and add users (log in again for membership to apply)
sudo groupadd gpuusers
sudo usermod -aG gpuusers alice
# 4. Edit /etc/resourced/config.toml: uncomment/set socket_group = "gpuusers" (and any resource checks)
# 5. Preview, then install and start
sudo /opt/resourced/bin/gpuctl service enable --system --dry-run
sudo /opt/resourced/bin/gpuctl service enable --system # creates the account, writes the unit, enables and starts it
gpuctl statusNotes:
- Set
socket_groupbefore step 5. Without it the socket is private (0600, owned byresourced) and ordinary users getno permission to use ... socket. The unit adds the group to the service account automatically. - The CLI finds the socket by itself (
--socket,RESOURCED_SOCKET, the config'sruntime_dir,$XDG_RUNTIME_DIR/resourced/, then/run/resourced/), so users of the shared install need no configuration. - Run only one daemon per set of GPUs:
gpuctl service disable --userany per-user service first. - System services start at boot; lingering is not needed.
- Update:
sudo /opt/resourced/bin/pip install --upgrade git+https://github.com/hansbuehler/resourced, thensudo systemctl restart resourced(claims survive). - Remove:
sudo /opt/resourced/bin/gpuctl service disable --system. - Logs:
journalctl -u resourced; state:systemctl status resourced.
gpuctl service enable writes these for you (with the real interpreter and config path). To install by hand, save
the user unit as ~/.config/systemd/user/resourced.service and run
systemctl --user daemon-reload && systemctl --user enable --now resourced:
[Unit]
Description=resourced: GPU allocation and resource limits
[Service]
Type=simple
ExecStart=/path/to/python -m resourced.daemon --config /path/to/config.toml
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.targetFor the system unit, create the account first
(sudo useradd --system --no-create-home --shell /usr/sbin/nologin resourced), save it as
/etc/systemd/system/resourced.service, and run sudo systemctl daemon-reload && sudo systemctl enable --now resourced:
[Unit]
Description=resourced: GPU allocation and resource limits
After=local-fs.target
[Service]
Type=simple
User=resourced
SupplementaryGroups=gpuusers
Environment=RESOURCED_RUNTIME_DIR=/run/resourced
ExecStart=/opt/resourced/bin/python -m resourced.daemon --config /etc/resourced/config.toml
RuntimeDirectory=resourced
RuntimeDirectoryMode=0755
RuntimeDirectoryPreserve=yes
Restart=on-failure
RestartSec=2
NoNewPrivileges=yes
ProtectSystem=full
[Install]
WantedBy=multi-user.targetDo not add PrivateTmp or similar settings that hide the socket or the monitored filesystems.
gpuctl run --gpus 2 -- python train.pyThe service picks 2 free GPUs, starts the command with CUDA_VISIBLE_DEVICES set to their UUIDs, and releases
them when the command exits (normally, on failure, or on a signal). The command's exit code is returned. If not
enough GPUs are free it fails immediately with exit code 3; retry later, use the CPU, or ask the user.
Run several jobs at once, each gets its own GPUs.
For compiling, testing and tuning where the GPU must stay yours between commands:
gpuctl claim --gpus 1 --label jax-dev --task-name "my task" --owner-pid <PID of your long-lived process>
gpuctl run --allocation <ref> -- python smoke.py # as often as you like
eval "$(gpuctl env --allocation <ref>)" # or pin a shell yourself
gpuctl release --allocation <ref> # always release when doneThe claim stays until explicitly released. The owner PID (for an agent: its own Codex/Claude process) is
required, is shown in gpuctl status, and is what gpuctl verify uses to tell your processes from others.
One-off runs never use a development claim's GPUs.
Keep programs that do not need a GPU (tests, data preparation, analysis) off the GPUs explicitly. JAX opens a
context on every visible GPU when it starts, even for CPU work, including GPUs that resourced has granted to
other jobs; gpuctl verify then reports FOREIGN PROCESS or UNCLAIMED USE, and new claims skip those GPUs.
- JAX:
JAX_PLATFORMS=cpu. - PyTorch and other CUDA frameworks:
CUDA_VISIBLE_DEVICES=(empty) hides all GPUs.
For a pytest suite, enforce it in the top-level conftest.py, and only allow GPU tests when pytest itself runs
on a claim (gpuctl run sets RESOURCED_ALLOCATION):
import os
import pytest
ON_CLAIMED_GPU = bool(os.environ.get("RESOURCED_ALLOCATION"))
if not ON_CLAIMED_GPU:
os.environ["JAX_PLATFORMS"] = "cpu" # before any test module imports JAX
def pytest_collection_modifyitems(config, items):
if not ON_CLAIMED_GPU:
skip = pytest.mark.skip(reason="needs a GPU: gpuctl run --gpus 1 -- python -m pytest -m gpu")
for item in items:
if "gpu" in item.keywords:
item.add_marker(skip)gpuctl status [--json] # who holds which GPU: task name, user, mode, age, owner PID and whether it is alive
gpuctl free # how many are available
gpuctl verify # compare claims with the processes actually on the GPUs (exit 1 on problems)
gpuctl release --force --gpu gpu1 --reason "agent crashed" # recover a lost claim, no TTY neededverify reports OK, IDLE CLAIM (claimed, nothing running), UNCLAIMED USE (a process on an available GPU),
FOREIGN PROCESS (a process on a claimed GPU that is not the claimant's), and MISSING (GPU not seen by the
driver). UNKNOWN means nvidia-smi did not answer.
What is checked, and when:
- Claim: an available GPU that has any compute process is an error (something bypassed the tool or a release was missed). It is logged, not granted, and listed with its PIDs; another GPU is used if possible.
- Release: an explicit release is refused while processes still use the GPU (stop them, or
--force). For one-off runs,gpuctlwaits up to 10 seconds (RESOURCED_RELEASE_WAIT) for the driver to clear the GPU, then releases anyway with a warning, and never changes the command's exit code. - If
nvidia-smiis unavailable the checks are reported as unknown and claims and releases proceed with a warning.
A process belongs to a claim if it is the owner PID (development) or the launched command (one-off), or a descendant of it. PIDs are stored with their start time, so a reused PID is not mistaken for the original.
import os
from resourced import Client
client = Client() # finds the socket automatically
claim = client.claim(mode="dev", gpus=1, task_name="my task", owner_pid=os.getpid())
env = claim["env"] # {"CUDA_VISIBLE_DEVICES": ..., "RESOURCED_ALLOCATION": ...}
client.release(claim["allocation"]["ref"])Errors raise resourced.ResourcedError with .code and .message.
gpuctl agent-instructions prints a short snippet for CLAUDE.md / AGENTS.md. For a machine-wide baseline
(project-independent resource guardrails: output directories, progress logging, detached-job environment
variables, gpuctl resources thresholds and STOP handling, treating Git trees as source), merge
examples/HOST_AGENTS.md and the gpuctl agent-instructions output into the agent's
user-level instructions file, which applies to every project of that user:
| Agent | User-level file | Machine-wide (all users) |
|---|---|---|
| Codex CLI | ~/.codex/AGENTS.md |
— |
| Claude Code | ~/.claude/CLAUDE.md |
/etc/claude-code/CLAUDE.md |
examples/PROJECT_AGENTS.md is a starting template for a single project's own
AGENTS.md / CLAUDE.md on top of that baseline. Remove any older GPU or resource policy from project files, so
agents do not follow two sets of rules. These files are instructions, not enforcement: gpuctl verify is what
detects a program that bypassed gpuctl.
gpuctl talks to the daemon over a Unix socket. Agents that run commands in a sandbox must be allowed to connect
to it, otherwise gpuctl reports no permission to use ... socket and gpuctl resources falls back to measuring
locally.
-
Codex CLI (tested with 0.149.1 on Linux) treats a Unix socket connection as network access. Adding the runtime directory to
writable_rootsdoes not help. In~/.codex/config.toml:sandbox_mode = "workspace-write" [sandbox_workspace_write] network_access = true
This also gives sandboxed commands general network access. With the
network_proxyfeature enabled, Unix socket destinations additionally need an explicit allow rule (not tested here). -
Check it from inside the sandbox:
codex sandbox -c 'sandbox_mode="workspace-write"' gpuctl status(use the full path togpuctlif~/.local/binis not on the sandbox'sPATH). -
GPUs are invisible inside the Codex sandbox. It gives commands a fresh
/devwithout the NVIDIA device files, and neitherwritable_rootsnor permission profiles can add them (tested with 0.149.1). A claim made in the sandbox succeeds, but the program then finds no GPU. Since GPU work is Python, let onlygpuctl run --gpus N -- python ...start outside the sandbox, with a Codex rule in~/.codex/rules/default.rules(adjust the paths to your install and interpreters):prefix_rule( pattern=[["gpuctl", "/home/hans/.local/bin/gpuctl"], "run", "--gpus", ["1", "2", "3", "4"], "--", ["python", "python3", "/home/hans/vaae/bin/python", "/home/hans/vaae/bin/python3"]], decision="allow", )Agents must then launch GPU processes exactly in that form; a command started after
eval "$(gpuctl env ...)", with other options before--gpus, or throughgpuctl run --allocation REF(the reference changes every time) still runs in the sandbox unless you approve it. Anything after-- pythonruns unsandboxed without a prompt.nvidia-smineeds no rule: the daemon runs it outside the sandbox. Check the rule withcodex execpolicy check --rules ~/.codex/rules/default.rules gpuctl run --gpus 1 -- python x.py. -
Other agents: allow connecting to
$XDG_RUNTIME_DIR/resourced/resourced.sock(per-user install) or/run/resourced/resourced.sock(system install), and give GPU commands access to/dev/nvidia*.
0 ok, 1 other error, 2 bad usage or config, 3 not enough GPUs, 4 GPUs busy with unknown processes,
5 daemon unreachable, 6 unknown or stale allocation. gpuctl run returns the command's own exit code
(128+n if it died from signal n); errors from gpuctl itself are prefixed with gpuctl:.
gpuctl resources [--json]Shows the configured limits and live measurements (RAM from MemAvailable, disk space from f_bavail of the
filesystem holding each path). Limits stay visible even if a measurement fails; a failed, timed-out (10 s) or missing
path is reported as ERROR/UNAVAILABLE, never as healthy. If the daemon is down it measures locally.
Limits are inclusive: a level is reached at its value.
| Key | Reached when |
|---|---|
warn_free_gib, stop_free_gib, warn_free_pct, stop_free_pct |
free value <= limit |
warn_used_pct, stop_used_pct |
used % >= limit |
The highest level reached is the health (OK, WARN, STOP). Resource levels never block GPU claims and never
kill or clean up anything; tasks are responsible for following them. Level changes are logged.
Lookup: --config, RESOURCED_CONFIG, $XDG_CONFIG_HOME/resourced/config.toml (default
~/.config/resourced/config.toml), /etc/resourced/config.toml. See examples/config.toml.
Restart the daemon to apply changes; an invalid file makes it refuse to start with a clear message.
[daemon]
runtime_dir = "..." # default: $XDG_RUNTIME_DIR/resourced (system unit: /run/resourced)
socket_group = "gpuusers" # shared installs: group allowed to use the socket
sample_interval = 30 # seconds between resource samples
probe_timeout = 10 # seconds before nvidia-smi / a disk probe counts as failed
nvidia_smi = "nvidia-smi"
[[gpu]] # only listed GPUs are managed
uuid = "GPU-..." # authoritative
alias = "gpu0" # optional, default gpuN
index = 0 # optional display hint
[[check]]
name = "ram"
type = "ram" # or "disk" (then also: path = "/some/mount")
warn_free_pct = 15 # any of warn_/stop_ free_gib, free_pct, used_pct
stop_free_pct = 10There is no database and nothing is stored in /tmp. The runtime directory (tmpfs) holds the socket,
claims.json, and the latest resource snapshot.
- Daemon restart or crash: claims are kept and restored (the systemd unit restarts it automatically).
- Reboot: all claims are gone (and a stored boot ID discards stale files); everything asks for GPUs again.
- A second daemon on the same runtime directory refuses to start; a symlinked runtime directory is refused.
- Logs and audit lines (claims, releases, force releases, resource level changes) go to the journal:
journalctl --user -u resourced(or without--userfor the system unit).
- Cooperative only; a program that ignores
gpuctlis detected byverifyand at claim time, not stopped. - A process that detaches (daemonizes, reparented to init) loses its ancestry and shows up as
FOREIGN PROCESSand is not tracked at release; the same happens to a claim whose owner PID has exited (for example an agent that restarted).gpuctl statusmarks a dead owner/wrapperDEAD; release the claim or--force. - If a
gpuctl runwrapper is SIGKILLed, its GPUs stay allocated untilgpuctl release --force. gpuctl runforwards SIGTERM and SIGHUP to the command; Ctrl-C reaches it through the terminal. A SIGINT sent only to the wrapper is not forwarded.- Process checks are point-in-time snapshots and not atomic with a program that starts right after a grant. Inside containers PIDs may be namespaced, so such processes cannot be attributed. Only compute processes count (graphics-only processes and MIG partitions are ignored).
- Anyone who can reach the socket can claim, release and force release; access is controlled by the socket mode and group only.
| Symptom | Check |
|---|---|
cannot find the resourced socket |
Is the daemon running? systemctl --user status resourced, gpuctl service enable --user |
no permission to use ... socket |
Inside a coding agent's sandbox: allow socket access, see Sandboxed coding agents. Otherwise join the socket group of the shared install and log in again; if no socket_group is set in the config, set it and restart the service |
System service fails to start (status=203/EXEC) |
The Python in ExecStart is not readable by resourced (home directory?); reinstall under /opt and re-run service enable --system |
nvidia-smi not found ... in gpuctl status or the log |
gpuctl init records the path it found; otherwise the daemon searches PATH, /usr/lib/wsl/lib, /usr/bin, /usr/local/bin. Install the driver or set nvidia_smi = "/full/path" under [daemon] |
| Daemon exits right away | Run resourced --config <file> in a terminal; messages are printed (bad config, duplicate daemon, ...) |
XDG_RUNTIME_DIR is not set |
Set runtime_dir in the config, or use a login session / lingering |
| Claims vanish after logout | Per-user service without lingering: sudo loginctl enable-linger $USER |
unclaimed GPU(s) have running processes |
Something is using a GPU outside resourced; gpuctl verify lists the PIDs |
The same PID on every GPU (FOREIGN PROCESS / UNCLAIMED USE) |
Usually a CPU-only JAX program such as a test run; start it with JAX_PLATFORMS=cpu, see CPU-only programs |
| A GPU stays claimed | gpuctl status shows the owner; if it is DEAD, gpuctl release --force --gpu <gpu> |
pip install -e '.[test]'
pytestTests are fast, offline and CPU-only (fake GPUs and temporary runtime directories). BUILD.md is the
record of the agent instructions this project was built from.
License: Apache-2.0.