proc-warden
Status: alpha (0.1.x). The exit-code contract and state names are stable; everything else may change without notice until 1.0.
Process lifetime you can reason about. One small CLI, proc, that makes
launching, watching, and killing local processes unambiguous — so that neither you
nor an agent ever has to guess whether something is running, why it stopped, or
who is holding the GPU.
It is a thin wrapper over systemd-run --user. systemd is already the supervisor;
this repo contributes the protocol, not the machinery.
The problem it solves
Four familiar failure modes, three of which are the same bug:
| Symptom | Root cause |
|---|---|
A queue wrapper waits forever on pgrep -f "python.*train" |
Identity inferred from command-line text — the wrapper's own argv matches the pattern, so it waits for itself |
| "Is that job still running, or did it die an hour ago?" | Same: nothing durable ties a name to a process |
| GPU memory still occupied after the job is gone | kill $PID kills the parent; XLA compile workers and forked children survive |
| A log goes quiet and you can't tell finished from hung | No distinction between ready, exited, and timed out |
proc fixes the class, not the instances: a process is identified by a name you
choose at launch, held by the kernel in a cgroup, with its exit status recorded
where it outlives the process.
Install
pip install systemd-proc
The PyPI distribution is
systemd-proc, notproc-warden: PyPI already has an unrelatedprocwarden(a Python library for supervising subprocesses inside one program), and it compares names with separators stripped, soproc-wardencollides with it. The repo, the import packageproc_warden, and the commands are unaffected.
That installs two console scripts, proc and proc-warden, which are the same
program. Every example here and in skills/proc-lifecycle/SKILL.md says proc;
use proc-warden if you already have something else called proc.
There are no runtime dependencies, deliberately — proc is a protocol over
tools you already have, and a dependency would undercut that claim. From a clone,
the repo runs with nothing installed at all:
mkdir -p ~/.local/bin # or anywhere on PATH
ln -s "$PWD/proc" ~/.local/bin/proc
mkdir -p ~/.claude/skills
ln -s "$PWD/skills/proc-lifecycle" ~/.claude/skills/proc-lifecycle
Requires Linux with a systemd user manager (systemctl --user is-system-running
must not say offline), /usr/bin/flock, and Python 3.10+. Works under WSL2 —
see Limitations.
Use
# launch something long, come back to it later
proc run pond -- python -m planktonica
proc status pond
proc logs pond -f # follows, and stops when the process does
proc stop pond # SIGTERM then SIGKILL the whole cgroup
# a server, with a real readiness check instead of `sleep 5`
proc run api -- python -m myapp.server
proc wait api --ready 'Uvicorn running' --timeout 60 || echo "never came up"
# a GPU job that holds an exclusive lease on device 0
proc run train --gpu -- python train.py # fails fast if the GPU is taken
proc run train2 --gpu --gpu-wait 3600 -- python train2.py # queues for it instead
proc ls # every run, one greppable line each
proc gpu # device memory and who holds each lease
proc gc # reap finished units, report GPU memory that never came back
Everything after -- is the command, verbatim.
Exit codes
proc is meant to be scripted, so the codes are a contract:
| Code | Meaning |
|---|---|
| 0 | the asked-for condition holds (ready, or exited cleanly) |
| 1 | the managed process failed, or died before becoming ready |
| 2 | timeout — we stopped waiting; the process is untouched and still running |
| 3 | usage error, or no such run |
| 4 | busy — that name is already running, or the GPU lease is held |
| 5 | this machine can't support the operation |
wait never kills anything. A timeout is a statement about your patience, not
about the process.
run reports what it can see by the time it returns: if the process is already
FAILED, KILLED, OOM, or LOST when it looks, that is exit 1. It looks only
once, so a process that dies a second later still exits 0 — detecting that is
what wait is for.
States
RUNNING, EXITED (0), FAILED (nonzero), KILLED (signal), OOM,
LEASE_BUSY (never started; the GPU lease was held), and LOST — no exit status
was recorded and no unit exists, which is what you see after wsl --shutdown
takes the user manager with it. LOST is not RUNNING, and it is not success.
Limitations
Stated plainly, because the whole point is to not be surprised:
- No queue beyond depth 1. A GPU lease serializes runs on one device, which is
all an 8 GB laptop GPU can do anyway. For real campaign queueing (N configs,
concurrency limits, groups) the right answer is pueue,
deliberately deferred — see
docs/DESIGN.md. flockordering is not FIFO. Several runs queued with--gpu-waitwill each get the lease eventually, in no guaranteed order. To control order, serialize in the shell withproc waitbetween launches.- Per-process GPU attribution is unavailable under WSL2.
nvidia-smi --query-compute-appsreturns an empty list even when memory is in use, so leak detection is baseline-diff and device-wide, not per-PID. PROC_HOMEmust not contain spaces (it is interpolated into a unit's shell redirection).procrefuses to start otherwise rather than misbehave.- A
--gpu-waitrun reportsRUNNINGwhile it is still queued for the lease; its stdout stays empty until it acquires. PYTHONUNBUFFERED=1is set by default, because stdout is a file and Python would otherwise block-buffer a startup readiness marker out of existence. Override with--env PYTHONUNBUFFERED=if you need the default buffering.
State on disk
$PROC_HOME (default ~/.local/state/proc), reconstructible after a reboot:
runs/<name>/meta.json # argv, resolved binary, cwd, env, GPU baseline, start time
runs/<name>/stdout # stdout+stderr, merged, plain file
runs/<name>/status # code=/status=/result= written by the unit's ExecStopPost
leases/gpu<N>.lock # the lease; held by an fd, released by the kernel
Tests
98 tests in two halves.
tests/test_proc.py drives the real systemd user manager — no mocks, because
the entire claim is that the kernel and systemd hold state we used to guess at.
Each test is named for the failure mode it forbids: the self-matching pgrep
deadlock, a setsid-escaped grandchild surviving stop, a vanished unit reading
as RUNNING, a readiness marker split across two writes.
tests/test_unit.py covers the decision-making around those calls — naming,
status parsing, the state machine, environment assembly, argv resolution, the
-- split — and needs no systemd, so it runs anywhere.
pip install -e ".[test]"
pytest -q
No GPU is needed: a lease is just a lock file. Coverage requires subprocess tracking to be meaningful (86% with, 38% without) — see docs/CONTRIBUTING.md, which also covers what to do when a globally installed pytest plugin breaks collection.
Contributing
docs/CONTRIBUTING.md for layout and test conventions, docs/DESIGN.md for the six invariants and the rejected alternatives, docs/RELEASING.md for cutting a version.
Citation
If you use this in published work, cite it via CITATION.cff — GitHub renders a Cite this repository button from it.
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file systemd_proc-0.1.3.tar.gz.
File metadata
- Download URL: systemd_proc-0.1.3.tar.gz
- Upload date:
- Size: 36.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f5f91299b993e8ea7c10099629b067cb1048fe2bd31a1b6136a63973dbd95a90
|
|
| MD5 |
2e264882e1ccd0ba915e181e1456b135
|
|
| BLAKE2b-256 |
5dca7d46463c25f279352c24d7de20d4bf203b5f05632e1a7692267d0a5e8a01
|
Provenance
The following attestation bundles were made for systemd_proc-0.1.3.tar.gz:
Publisher:
publish-pypi.yml on JimGalasyn/proc-warden
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
systemd_proc-0.1.3.tar.gz -
Subject digest:
f5f91299b993e8ea7c10099629b067cb1048fe2bd31a1b6136a63973dbd95a90 - Sigstore transparency entry: 2257836224
- Sigstore integration time:
-
Permalink:
JimGalasyn/proc-warden@3fa603851b04e3539ebd470049c1001dfb6a50c2 -
Branch / Tag:
refs/tags/v0.1.3 - Owner: https://github.com/JimGalasyn
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@3fa603851b04e3539ebd470049c1001dfb6a50c2 -
Trigger Event:
release
-
Statement type:
File details
Details for the file systemd_proc-0.1.3-py3-none-any.whl.
File metadata
- Download URL: systemd_proc-0.1.3-py3-none-any.whl
- Upload date:
- Size: 16.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7c430287b4f2c9e5e6ab11719d727e6215213edef38d3aefbce2bd1caaa64322
|
|
| MD5 |
415079b2c2df6ee977fb5404901c79c5
|
|
| BLAKE2b-256 |
43d48772f68dd1fbac1011ccb1649cd6d782d095784dedb397dd7dcab902d4fd
|
Provenance
The following attestation bundles were made for systemd_proc-0.1.3-py3-none-any.whl:
Publisher:
publish-pypi.yml on JimGalasyn/proc-warden
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
systemd_proc-0.1.3-py3-none-any.whl -
Subject digest:
7c430287b4f2c9e5e6ab11719d727e6215213edef38d3aefbce2bd1caaa64322 - Sigstore transparency entry: 2257836680
- Sigstore integration time:
-
Permalink:
JimGalasyn/proc-warden@3fa603851b04e3539ebd470049c1001dfb6a50c2 -
Branch / Tag:
refs/tags/v0.1.3 - Owner: https://github.com/JimGalasyn
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@3fa603851b04e3539ebd470049c1001dfb6a50c2 -
Trigger Event:
release
-
Statement type: