jobwarden 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Séverin Férard
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,303 @@
1
+ Metadata-Version: 2.4
2
+ Name: jobwarden
3
+ Version: 0.1.0
4
+ Summary: Watch long-running jobs live, locally or over SSH, and wake your AI agent when they need attention
5
+ Author: Séverin Férard
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/severinferard/jobwarden
8
+ Project-URL: Issues, https://github.com/severinferard/jobwarden/issues
9
+ Keywords: ai-agents,claude-code,gpu,job-monitoring,ssh,training
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: System :: Monitoring
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ <div align="center">
24
+ <picture>
25
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/severinferard/jobwarden/main/docs/logo-dark.svg">
26
+ <img src="https://raw.githubusercontent.com/severinferard/jobwarden/main/docs/logo-light.svg" alt="" width="96">
27
+ </picture>
28
+
29
+ <h1>jobwarden</h1>
30
+ </div>
31
+
32
+ **Watch long-running jobs live, on this machine or over SSH, and let your AI agent get woken when they need attention.**
33
+
34
+ ![A real Claude Code session: it starts a training run, a health check wakes it when the loss diverges, and it stops the job and finds the cause](https://raw.githubusercontent.com/severinferard/jobwarden/main/docs/demo.gif)
35
+
36
+ Start a job, see its progress as it runs, and walk away. It's built for AI coding agents like Claude Code, which shouldn't burn a session polling a training run or write a new watcher script each time: the agent waits on one command, which exits with a clear result as soon as the job finishes, fails, stalls, or fails your own health check. You follow along in the same live view.
37
+
38
+ ```sh
39
+ jobwarden run train --host box1 --gpu 2080 -- 'python train.py' # leave out --host to run on this machine
40
+ jobwarden watch train # live progress: bar, ETA, metrics
41
+ jobwarden wait train --check 'python3 tools/check_training.py' # blocks, then exits with what happened
42
+ ```
43
+
44
+ - **Live progress.** Print one simple line and you get a bar, an ETA, metrics and stall detection, for one job or all of them.
45
+ - **Made for agents.** One blocking command, one line of output, one exit code per outcome, and JSON wherever a program wants it.
46
+ - **Local or remote.** The same commands work on your own machine and on hosts over SSH.
47
+ - **Jobs survive you.** A job on a remote host keeps running if your laptop sleeps or SSH drops.
48
+ - **No daemon, no server, no database.** The only record is a JSON file next to the job, so any viewer can come and go.
49
+ - **"Exit 0" isn't taken on trust.** A job that ran too briefly or didn't write its output counts as failed.
50
+ - **Safe on shared GPUs.** A busy card is refused, and a reservation stops two jobs picking the same one.
51
+ - **No dependencies.** It's standard-library Python 3.10+, on your machine and on the hosts.
52
+
53
+ It suits a laptop with a few SSH boxes, or rented pods. It isn't a cluster scheduler. It's been used from macOS with Linux hosts; other setups, and real rented pods, are untested.
54
+
55
+ ## Install
56
+
57
+ ```sh
58
+ uv tool install git+https://github.com/severinferard/jobwarden # or: pipx install git+https://...
59
+ jobwarden demo # a fake training run: no GPU or SSH needed
60
+ ```
61
+
62
+ Without any setup, jobs run on your own machine:
63
+
64
+ ```sh
65
+ jobwarden run hello -- 'sleep 30; echo done'
66
+ jobwarden wait hello
67
+ ```
68
+
69
+ To use a remote host, run these inside your project. Hosts are saved per project, in `.jobwarden/hosts.json`:
70
+
71
+ ```sh
72
+ jobwarden host add box1 --ssh user@10.0.0.2 # optional: --jump user@gateway --port 2222
73
+ jobwarden install box1 # copies jobrun to the host and names its GPUs
74
+ ```
75
+
76
+ jobwarden finds the nearest `.jobwarden/hosts.json` from the current folder upwards. Outside a project it only knows your own machine, and says so. `ssh` to the host must already work without a password prompt, and the host needs `python3` 3.10+.
77
+
78
+ ## For AI agents
79
+
80
+ The usual loop for a coding agent:
81
+
82
+ 1. Launch with `jobwarden run`, setting `--min-runtime` and `--expect` whenever you know what a healthy run looks like.
83
+ 2. Start `jobwarden wait <name>` as a background command, adding `--check` if a run can go wrong without failing.
84
+ 3. When it exits, read the line and the exit code ([the full list](#waiting-for-a-job)). To diagnose, read `~/.jobwarden/jobs/<name>/log` on the job's host with normal tools, or use `jobwarden logs <name>` for the last lines.
85
+
86
+ In Claude Code, a background command's exit wakes the session, so the agent needs no polling at all. Any agent or script that can run a command and read its exit code can use the same loop, but how it gets woken depends on that tool. `examples/claude-code/job_launch.py` is a small Claude Code hook that reminds a session to do this after a launch.
87
+
88
+ ## Running a job
89
+
90
+ ```sh
91
+ jobwarden run train --host box1 --gpu 2080 --wait-gpu \
92
+ --cwd ~/project --project demo --summary "Fine-tune v3 on the new dataset" \
93
+ --min-runtime 600 --expect out/model.gguf \
94
+ -- 'python train.py'
95
+ ```
96
+
97
+ | Option | What it does |
98
+ |:--|:--|
99
+ | `--host` | where to run (default: this machine) |
100
+ | `--gpu` | a card's short name, `any` or a UUID. With no `--gpu`, the job sees no GPU |
101
+ | `--wait-gpu` | queue for a busy card instead of being refused |
102
+ | `--force` | use a busy card anyway |
103
+ | `--min-runtime SECONDS` | exit 0 sooner than this counts as failed |
104
+ | `--expect FILE` | exit 0 without this file, non-empty and newer than the start, counts as failed (repeatable) |
105
+ | `--project`, `--summary`, `--cwd` | a label for grouping, text for the viewer, and the working folder |
106
+
107
+ **Quote the command as one argument**, so that variables expand on the host.
108
+
109
+ **Progress:** print lines like these and you get a bar, an ETA, metrics and stall detection. The word `step` can be anything.
110
+
111
+ ```
112
+ 12:30:01 step 120/900 loss 0.85 1.2s/step
113
+ 12:30:01 stage export
114
+ ```
115
+
116
+ **Docker:** use both variables, and no `-d`. jobwarden already detaches the job, and it refuses a `docker run` it can see breaking these rules. Put them in any script your job runs too, since it can't check inside those.
117
+
118
+ ```sh
119
+ jobwarden run train --host box1 --gpu 2080 -- \
120
+ 'docker run --rm --name "$JOB_CONTAINER" --gpus "device=$JOB_GPU" img python train.py'
121
+ ```
122
+
123
+ ## Following jobs
124
+
125
+ ```sh
126
+ jobwarden watch train # one job in detail; --once prints one frame
127
+ jobwarden watch --project demo # every job, compact; picks up new ones and never ends
128
+ jobwarden status # a one-off list; --all includes hidden jobs
129
+ jobwarden logs train -n 50 -f # finds the host for you; -f follows until the job ends
130
+ jobwarden stop train --grace 30 # SIGTERM, then SIGKILL after the grace
131
+ jobwarden adopt train # a job's supervisor died: track it again
132
+ jobwarden clear train # hide a job from views; the job carries on
133
+ ```
134
+
135
+ **Where a job's files are:** in `~/.jobwarden/jobs/<name>/` on the host it runs on, and that location is stable, so read them with whatever tool you like:
136
+
137
+ | File | What it is |
138
+ |:--|:--|
139
+ | `~/.jobwarden/jobs/<name>/log` | everything the job printed, in full |
140
+ | `~/.jobwarden/jobs/<name>/status.json` | its state, exit code, last progress and heartbeat |
141
+ | `~/.jobwarden/jobs/<name>/prev/<time>/` | the log and status of earlier runs under that name |
142
+
143
+ The job itself gets the folder as `$JOB_DIR`, and `jobwarden status --json` gives each job's `host` and `dir`. `jobwarden logs` just saves you finding the host. For anything else, use normal tools:
144
+
145
+ ```sh
146
+ ssh box1 'grep -n "loss" ~/.jobwarden/jobs/train/log'
147
+ head -20 ~/.jobwarden/jobs/train/log # a job on this machine
148
+ ```
149
+
150
+ Viewers are read-only, so closing one interrupts nothing. Finished jobs leave views after 60 minutes (`$JOBWARDEN_KEEP_MIN`), and nothing in `~/.jobwarden/jobs` is ever deleted.
151
+
152
+ `stop` only reaches that job's process group and its container, and records the state `stopped`. It never stops a pod: a job ending on a host added with `--temporary` is reported as "may still be billing".
153
+
154
+ To show something specific to a job in `watch`, such as a results table or live GPU numbers, pass `--config job.py`. See `jobwarden watch --help` and `examples/job_config.py`.
155
+
156
+ ## Waiting for a job
157
+
158
+ `jobwarden wait` blocks, prints one line, and exits with a code that says why. Start it as a background command from an AI agent or a script.
159
+
160
+ | Exit | Line starts with | Meaning |
161
+ |:--|:--|:--|
162
+ | 0 | `JOB train DONE:` | finished properly |
163
+ | 1 | `JOB train FAILED:` | see [how the outcome is decided](#how-the-outcome-is-decided) |
164
+ | 3 | `JOB train UNREACHABLE:` | host silent for `--unreachable` minutes (default 10) |
165
+ | 4 | `JOB train is on several hosts` | use `--host` |
166
+ | 5 | `JOB train CLEARED:` / `NEVER APPEARED:` | record gone, or not found after `--appear` minutes (default 10) |
167
+ | 6 | `JOB train CHECK:` / `CHECK BROKEN:` | your `--check` spoke up, or doesn't work |
168
+ | 7 | `JOB train STOPPED:` | someone ran `jobwarden stop` |
169
+ | 8 | `JOB train STALLED:` | no progress for `--stall` minutes (default 20) |
170
+ | 2, 70 | no such line | bad usage or unknown host; jobwarden itself crashed |
171
+
172
+ **Your own health check** catches what only your project can tell, such as a loss going to NaN, while there's still time to act:
173
+
174
+ ```sh
175
+ jobwarden wait train --check-every 5 \
176
+ --check '! grep -q "loss nan" "$JOB_DIR/log" || { echo "loss is NaN"; exit 1; }'
177
+ ```
178
+
179
+ It runs on the job's host, in the job's folder, with `$JOB_NAME` and `$JOB_DIR` set, and has 30 s. Exit 0 means all is well, and exit 1 wakes you with whatever it printed. Anything else counts as a broken check, which wakes you too. The job keeps running either way. Keep checks read-only: jobwarden doesn't enforce that.
180
+
181
+ **Notifications:** `--notify` runs any command, so jobwarden doesn't depend on a particular terminal. It works on `wait` and `watch`; use one per job, or you'll get each alert twice.
182
+
183
+ ```sh
184
+ jobwarden wait train --notify 'osascript -e "display notification \"$JOBWARDEN_BODY\" with title \"$JOBWARDEN_TITLE\""'
185
+ jobwarden wait train --notify 'notify-send "$JOBWARDEN_TITLE" "$JOBWARDEN_BODY"'
186
+ ```
187
+
188
+ The command gets `$JOBWARDEN_TITLE`, `$JOBWARDEN_BODY`, `$JOBWARDEN_EVENT`, `$JOBWARDEN_JOB` and `$JOBWARDEN_HOST`.
189
+
190
+ For cmux, see [Using it in cmux](#using-it-in-cmux).
191
+
192
+ **Staying awake:** nothing notices a job's end while no reader is running, and a sleeping laptop isn't running one. The job itself carries on if it's on another machine, and the record is waiting when you're back. On a Mac, `caffeinate -is jobwarden wait train` prevents idle sleep, though not a closed lid.
193
+
194
+ ## Building your own dashboard
195
+
196
+ | | One reading | Live |
197
+ |:--|:--|:--|
198
+ | For people | `jobwarden status` | `jobwarden watch --project NAME` |
199
+ | For programs, any language | `jobwarden status --json` | `jobwarden watch --json` |
200
+ | In Python | `jobwarden.jobs()` | `jobwarden.live()` |
201
+
202
+ Often you need no code at all: run `jobwarden watch --project NAME` in a tmux pane or a terminal sidebar, such as [the cmux Dock](#using-it-in-cmux).
203
+
204
+ For a custom view in Python, `live()` reads in the background, so drawing never waits for a dead host:
205
+
206
+ ```python
207
+ import time
208
+ import jobwarden
209
+
210
+ live = jobwarden.live(project="demo", interval=10)
211
+ while True:
212
+ print(live.down()) # {host: why} for hosts not answering
213
+ for j in live.jobs(): # empty until the first hosts answer
214
+ print(j["name"], j["state"], j["step"], j["total"], j["eta_s"], j["metrics"])
215
+ time.sleep(2)
216
+ ```
217
+
218
+ `jobwarden.jobs()` reads the hosts right now, and can wait about 40 s on a dead one. Import from where the package is installed; a `uv tool` install has its own environment, so a script may need `sys.path` set as in `examples/project_dock.py`.
219
+
220
+ Every job has these fields: `name`, `host`, `dir`, `project`, `state` (`running`, `queued`, `orphan`, `unreachable`, `unreadable`, `done`, `failed`, `stopped`), `note`, `summary`, `gpu`, `step`, `total`, `frac`, `stage`, `metrics`, `eta_s`, `updated`, `last_at`, `start_at`, `ended_at`, `unreachable_since`, `temporary`, `cleared`, `log` (last lines), and in Python `status` (the raw `status.json`).
221
+
222
+ From another language, `jobwarden watch --json` prints one line per host at each read:
223
+
224
+ ```json
225
+ {"version": 1, "host": "box1", "reachable": true, "error": null, "jobs": [{"name": "train", "state": "running", "step": 120, "total": 900}]}
226
+ ```
227
+
228
+ ## Using it in cmux
229
+
230
+ jobwarden works in any terminal, and needs nothing from [cmux](https://cmux.com). If you do use cmux, two of its features fit well: the Dock, its right sidebar of terminals, and its notifications.
231
+
232
+ **A Dock with no code:** add a control to the project's `.cmux/dock.json`. Keep the `cwd`, because that's how jobwarden finds the project's `.jobwarden/hosts.json`.
233
+
234
+ ```json
235
+ {
236
+ "controls": [
237
+ { "id": "jobs", "title": "Jobs", "command": "jobwarden watch --project demo", "cwd": "." }
238
+ ]
239
+ }
240
+ ```
241
+
242
+ **A custom Dock:** for your own layout, or to show the hosts' GPUs next to the jobs, write a small script around [`jobwarden.live()`](#building-your-own-dashboard), keep it in the project, and make it the control's `command`. Since `live()` reads in the background, the script only has to redraw.
243
+
244
+ **Notifications:**
245
+
246
+ ```sh
247
+ jobwarden wait train --notify 'cmux notify --title "$JOBWARDEN_TITLE" --body "$JOBWARDEN_BODY"'
248
+ ```
249
+
250
+ Two limits come from how the Dock works:
251
+
252
+ - **`dock.json` only seeds a Dock with no saved state.** A Dock that's already open won't pick up the control, so add it by hand: `cmux new-pane --placement dock --command "jobwarden watch --project demo"`.
253
+ - **A window's Dock is shared by its workspaces.** A project's Dock stays on screen when you switch to another project's workspace in that window.
254
+
255
+ ## How it works
256
+
257
+ ```
258
+ your machine host
259
+ ──────────── ────
260
+ jobwarden run ───── ssh ───────────────► jobrun ──► supervisor ──► your job
261
+ │ │
262
+ jobwarden watch ──┐ ▼ ▼
263
+ jobwarden wait ──┼─ one shared ssh ───► reads ~/.jobwarden/jobs/<name>/status.json, log
264
+ jobwarden.live() ─┘ connection
265
+ ```
266
+
267
+ `jobrun` is one file, copied to `~/.local/bin` on the host. `jobwarden run` warns if the copy is out of date. Each job gets a small supervisor that starts it, follows its log and writes `status.json`. Every reader then reads that file directly: nothing about jobs is saved on your machine.
268
+
269
+ All readers of a host share one SSH connection, every command has a hard timeout, and failed connections back off, so a dozen viewers don't look like an attack to a strict network. `docs/design-notes.md` explains the reasoning.
270
+
271
+ ### How the outcome is decided
272
+
273
+ When the job's process ends, the supervisor records the first match:
274
+
275
+ | What happened | State |
276
+ |:--|:--|
277
+ | Ended by `jobwarden stop` | `stopped` |
278
+ | Killed by a signal, or a non-zero exit code | `failed` |
279
+ | Exit 0, but ran less than `--min-runtime` | `failed` |
280
+ | Exit 0, but an `--expect` file is missing, empty or older than the start | `failed` |
281
+ | Otherwise | `done` |
282
+
283
+ If the supervisor's heartbeat goes quiet for 60 s, readers report `orphan` when the job's process is still alive, and `failed` with an unknown exit code when it's gone. A reboot looks like this too, even if the job had in fact finished. `jobwarden adopt <name>` starts a new supervisor for an `orphan`. It can't learn the exit code, so the job then counts as `done` only if its `--min-runtime` and `--expect` checks pass, and as `failed` if it has none.
284
+
285
+ ## What it doesn't catch
286
+
287
+ - **Wrong results with exit 0.** `--expect` only checks that a file exists, isn't empty and is new, and `--check` stops when the job does.
288
+ - **Healthy but silent jobs.** With no output for `--stall` minutes, `wait` reports `STALLED` and says no progress line was ever seen. Give such jobs a larger `--stall`.
289
+ - **Jobs started without jobwarden.** They hold no reservation, and only count as busy once `nvidia-smi` shows them.
290
+ - **Processes that escape.** `stop` can't reach a process that moved itself to its own session, or a container started by a script without `--name "$JOB_CONTAINER"`.
291
+ - **Containers killed from outside.** If the `docker run` client dies while its container carries on, the client's exit code is what gets recorded.
292
+
293
+ ## Development
294
+
295
+ ```sh
296
+ python3 -m unittest discover -s tests -t .
297
+ ```
298
+
299
+ The tests take about 2 minutes and use no network, real GPU or billable service: SSH is a stub or a throwaway `sshd` on localhost, and `HOME` is a temp folder.
300
+
301
+ `examples/project_dock.py` and `examples/job_config.py` also use `hoststats`, a separate package for CPU and GPU numbers that isn't published yet.
302
+
303
+ MIT licence: see `LICENSE`.
@@ -0,0 +1,281 @@
1
+ <div align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/severinferard/jobwarden/main/docs/logo-dark.svg">
4
+ <img src="https://raw.githubusercontent.com/severinferard/jobwarden/main/docs/logo-light.svg" alt="" width="96">
5
+ </picture>
6
+
7
+ <h1>jobwarden</h1>
8
+ </div>
9
+
10
+ **Watch long-running jobs live, on this machine or over SSH, and let your AI agent get woken when they need attention.**
11
+
12
+ ![A real Claude Code session: it starts a training run, a health check wakes it when the loss diverges, and it stops the job and finds the cause](https://raw.githubusercontent.com/severinferard/jobwarden/main/docs/demo.gif)
13
+
14
+ Start a job, see its progress as it runs, and walk away. It's built for AI coding agents like Claude Code, which shouldn't burn a session polling a training run or write a new watcher script each time: the agent waits on one command, which exits with a clear result as soon as the job finishes, fails, stalls, or fails your own health check. You follow along in the same live view.
15
+
16
+ ```sh
17
+ jobwarden run train --host box1 --gpu 2080 -- 'python train.py' # leave out --host to run on this machine
18
+ jobwarden watch train # live progress: bar, ETA, metrics
19
+ jobwarden wait train --check 'python3 tools/check_training.py' # blocks, then exits with what happened
20
+ ```
21
+
22
+ - **Live progress.** Print one simple line and you get a bar, an ETA, metrics and stall detection, for one job or all of them.
23
+ - **Made for agents.** One blocking command, one line of output, one exit code per outcome, and JSON wherever a program wants it.
24
+ - **Local or remote.** The same commands work on your own machine and on hosts over SSH.
25
+ - **Jobs survive you.** A job on a remote host keeps running if your laptop sleeps or SSH drops.
26
+ - **No daemon, no server, no database.** The only record is a JSON file next to the job, so any viewer can come and go.
27
+ - **"Exit 0" isn't taken on trust.** A job that ran too briefly or didn't write its output counts as failed.
28
+ - **Safe on shared GPUs.** A busy card is refused, and a reservation stops two jobs picking the same one.
29
+ - **No dependencies.** It's standard-library Python 3.10+, on your machine and on the hosts.
30
+
31
+ It suits a laptop with a few SSH boxes, or rented pods. It isn't a cluster scheduler. It's been used from macOS with Linux hosts; other setups, and real rented pods, are untested.
32
+
33
+ ## Install
34
+
35
+ ```sh
36
+ uv tool install git+https://github.com/severinferard/jobwarden # or: pipx install git+https://...
37
+ jobwarden demo # a fake training run: no GPU or SSH needed
38
+ ```
39
+
40
+ Without any setup, jobs run on your own machine:
41
+
42
+ ```sh
43
+ jobwarden run hello -- 'sleep 30; echo done'
44
+ jobwarden wait hello
45
+ ```
46
+
47
+ To use a remote host, run these inside your project. Hosts are saved per project, in `.jobwarden/hosts.json`:
48
+
49
+ ```sh
50
+ jobwarden host add box1 --ssh user@10.0.0.2 # optional: --jump user@gateway --port 2222
51
+ jobwarden install box1 # copies jobrun to the host and names its GPUs
52
+ ```
53
+
54
+ jobwarden finds the nearest `.jobwarden/hosts.json` from the current folder upwards. Outside a project it only knows your own machine, and says so. `ssh` to the host must already work without a password prompt, and the host needs `python3` 3.10+.
55
+
56
+ ## For AI agents
57
+
58
+ The usual loop for a coding agent:
59
+
60
+ 1. Launch with `jobwarden run`, setting `--min-runtime` and `--expect` whenever you know what a healthy run looks like.
61
+ 2. Start `jobwarden wait <name>` as a background command, adding `--check` if a run can go wrong without failing.
62
+ 3. When it exits, read the line and the exit code ([the full list](#waiting-for-a-job)). To diagnose, read `~/.jobwarden/jobs/<name>/log` on the job's host with normal tools, or use `jobwarden logs <name>` for the last lines.
63
+
64
+ In Claude Code, a background command's exit wakes the session, so the agent needs no polling at all. Any agent or script that can run a command and read its exit code can use the same loop, but how it gets woken depends on that tool. `examples/claude-code/job_launch.py` is a small Claude Code hook that reminds a session to do this after a launch.
65
+
66
+ ## Running a job
67
+
68
+ ```sh
69
+ jobwarden run train --host box1 --gpu 2080 --wait-gpu \
70
+ --cwd ~/project --project demo --summary "Fine-tune v3 on the new dataset" \
71
+ --min-runtime 600 --expect out/model.gguf \
72
+ -- 'python train.py'
73
+ ```
74
+
75
+ | Option | What it does |
76
+ |:--|:--|
77
+ | `--host` | where to run (default: this machine) |
78
+ | `--gpu` | a card's short name, `any` or a UUID. With no `--gpu`, the job sees no GPU |
79
+ | `--wait-gpu` | queue for a busy card instead of being refused |
80
+ | `--force` | use a busy card anyway |
81
+ | `--min-runtime SECONDS` | exit 0 sooner than this counts as failed |
82
+ | `--expect FILE` | exit 0 without this file, non-empty and newer than the start, counts as failed (repeatable) |
83
+ | `--project`, `--summary`, `--cwd` | a label for grouping, text for the viewer, and the working folder |
84
+
85
+ **Quote the command as one argument**, so that variables expand on the host.
86
+
87
+ **Progress:** print lines like these and you get a bar, an ETA, metrics and stall detection. The word `step` can be anything.
88
+
89
+ ```
90
+ 12:30:01 step 120/900 loss 0.85 1.2s/step
91
+ 12:30:01 stage export
92
+ ```
93
+
94
+ **Docker:** use both variables, and no `-d`. jobwarden already detaches the job, and it refuses a `docker run` it can see breaking these rules. Put them in any script your job runs too, since it can't check inside those.
95
+
96
+ ```sh
97
+ jobwarden run train --host box1 --gpu 2080 -- \
98
+ 'docker run --rm --name "$JOB_CONTAINER" --gpus "device=$JOB_GPU" img python train.py'
99
+ ```
100
+
101
+ ## Following jobs
102
+
103
+ ```sh
104
+ jobwarden watch train # one job in detail; --once prints one frame
105
+ jobwarden watch --project demo # every job, compact; picks up new ones and never ends
106
+ jobwarden status # a one-off list; --all includes hidden jobs
107
+ jobwarden logs train -n 50 -f # finds the host for you; -f follows until the job ends
108
+ jobwarden stop train --grace 30 # SIGTERM, then SIGKILL after the grace
109
+ jobwarden adopt train # a job's supervisor died: track it again
110
+ jobwarden clear train # hide a job from views; the job carries on
111
+ ```
112
+
113
+ **Where a job's files are:** in `~/.jobwarden/jobs/<name>/` on the host it runs on, and that location is stable, so read them with whatever tool you like:
114
+
115
+ | File | What it is |
116
+ |:--|:--|
117
+ | `~/.jobwarden/jobs/<name>/log` | everything the job printed, in full |
118
+ | `~/.jobwarden/jobs/<name>/status.json` | its state, exit code, last progress and heartbeat |
119
+ | `~/.jobwarden/jobs/<name>/prev/<time>/` | the log and status of earlier runs under that name |
120
+
121
+ The job itself gets the folder as `$JOB_DIR`, and `jobwarden status --json` gives each job's `host` and `dir`. `jobwarden logs` just saves you finding the host. For anything else, use normal tools:
122
+
123
+ ```sh
124
+ ssh box1 'grep -n "loss" ~/.jobwarden/jobs/train/log'
125
+ head -20 ~/.jobwarden/jobs/train/log # a job on this machine
126
+ ```
127
+
128
+ Viewers are read-only, so closing one interrupts nothing. Finished jobs leave views after 60 minutes (`$JOBWARDEN_KEEP_MIN`), and nothing in `~/.jobwarden/jobs` is ever deleted.
129
+
130
+ `stop` only reaches that job's process group and its container, and records the state `stopped`. It never stops a pod: a job ending on a host added with `--temporary` is reported as "may still be billing".
131
+
132
+ To show something specific to a job in `watch`, such as a results table or live GPU numbers, pass `--config job.py`. See `jobwarden watch --help` and `examples/job_config.py`.
133
+
134
+ ## Waiting for a job
135
+
136
+ `jobwarden wait` blocks, prints one line, and exits with a code that says why. Start it as a background command from an AI agent or a script.
137
+
138
+ | Exit | Line starts with | Meaning |
139
+ |:--|:--|:--|
140
+ | 0 | `JOB train DONE:` | finished properly |
141
+ | 1 | `JOB train FAILED:` | see [how the outcome is decided](#how-the-outcome-is-decided) |
142
+ | 3 | `JOB train UNREACHABLE:` | host silent for `--unreachable` minutes (default 10) |
143
+ | 4 | `JOB train is on several hosts` | use `--host` |
144
+ | 5 | `JOB train CLEARED:` / `NEVER APPEARED:` | record gone, or not found after `--appear` minutes (default 10) |
145
+ | 6 | `JOB train CHECK:` / `CHECK BROKEN:` | your `--check` spoke up, or doesn't work |
146
+ | 7 | `JOB train STOPPED:` | someone ran `jobwarden stop` |
147
+ | 8 | `JOB train STALLED:` | no progress for `--stall` minutes (default 20) |
148
+ | 2, 70 | no such line | bad usage or unknown host; jobwarden itself crashed |
149
+
150
+ **Your own health check** catches what only your project can tell, such as a loss going to NaN, while there's still time to act:
151
+
152
+ ```sh
153
+ jobwarden wait train --check-every 5 \
154
+ --check '! grep -q "loss nan" "$JOB_DIR/log" || { echo "loss is NaN"; exit 1; }'
155
+ ```
156
+
157
+ It runs on the job's host, in the job's folder, with `$JOB_NAME` and `$JOB_DIR` set, and has 30 s. Exit 0 means all is well, and exit 1 wakes you with whatever it printed. Anything else counts as a broken check, which wakes you too. The job keeps running either way. Keep checks read-only: jobwarden doesn't enforce that.
158
+
159
+ **Notifications:** `--notify` runs any command, so jobwarden doesn't depend on a particular terminal. It works on `wait` and `watch`; use one per job, or you'll get each alert twice.
160
+
161
+ ```sh
162
+ jobwarden wait train --notify 'osascript -e "display notification \"$JOBWARDEN_BODY\" with title \"$JOBWARDEN_TITLE\""'
163
+ jobwarden wait train --notify 'notify-send "$JOBWARDEN_TITLE" "$JOBWARDEN_BODY"'
164
+ ```
165
+
166
+ The command gets `$JOBWARDEN_TITLE`, `$JOBWARDEN_BODY`, `$JOBWARDEN_EVENT`, `$JOBWARDEN_JOB` and `$JOBWARDEN_HOST`.
167
+
168
+ For cmux, see [Using it in cmux](#using-it-in-cmux).
169
+
170
+ **Staying awake:** nothing notices a job's end while no reader is running, and a sleeping laptop isn't running one. The job itself carries on if it's on another machine, and the record is waiting when you're back. On a Mac, `caffeinate -is jobwarden wait train` prevents idle sleep, though not a closed lid.
171
+
172
+ ## Building your own dashboard
173
+
174
+ | | One reading | Live |
175
+ |:--|:--|:--|
176
+ | For people | `jobwarden status` | `jobwarden watch --project NAME` |
177
+ | For programs, any language | `jobwarden status --json` | `jobwarden watch --json` |
178
+ | In Python | `jobwarden.jobs()` | `jobwarden.live()` |
179
+
180
+ Often you need no code at all: run `jobwarden watch --project NAME` in a tmux pane or a terminal sidebar, such as [the cmux Dock](#using-it-in-cmux).
181
+
182
+ For a custom view in Python, `live()` reads in the background, so drawing never waits for a dead host:
183
+
184
+ ```python
185
+ import time
186
+ import jobwarden
187
+
188
+ live = jobwarden.live(project="demo", interval=10)
189
+ while True:
190
+ print(live.down()) # {host: why} for hosts not answering
191
+ for j in live.jobs(): # empty until the first hosts answer
192
+ print(j["name"], j["state"], j["step"], j["total"], j["eta_s"], j["metrics"])
193
+ time.sleep(2)
194
+ ```
195
+
196
+ `jobwarden.jobs()` reads the hosts right now, and can wait about 40 s on a dead one. Import from where the package is installed; a `uv tool` install has its own environment, so a script may need `sys.path` set as in `examples/project_dock.py`.
197
+
198
+ Every job has these fields: `name`, `host`, `dir`, `project`, `state` (`running`, `queued`, `orphan`, `unreachable`, `unreadable`, `done`, `failed`, `stopped`), `note`, `summary`, `gpu`, `step`, `total`, `frac`, `stage`, `metrics`, `eta_s`, `updated`, `last_at`, `start_at`, `ended_at`, `unreachable_since`, `temporary`, `cleared`, `log` (last lines), and in Python `status` (the raw `status.json`).
199
+
200
+ From another language, `jobwarden watch --json` prints one line per host at each read:
201
+
202
+ ```json
203
+ {"version": 1, "host": "box1", "reachable": true, "error": null, "jobs": [{"name": "train", "state": "running", "step": 120, "total": 900}]}
204
+ ```
205
+
206
+ ## Using it in cmux
207
+
208
+ jobwarden works in any terminal, and needs nothing from [cmux](https://cmux.com). If you do use cmux, two of its features fit well: the Dock, its right sidebar of terminals, and its notifications.
209
+
210
+ **A Dock with no code:** add a control to the project's `.cmux/dock.json`. Keep the `cwd`, because that's how jobwarden finds the project's `.jobwarden/hosts.json`.
211
+
212
+ ```json
213
+ {
214
+ "controls": [
215
+ { "id": "jobs", "title": "Jobs", "command": "jobwarden watch --project demo", "cwd": "." }
216
+ ]
217
+ }
218
+ ```
219
+
220
+ **A custom Dock:** for your own layout, or to show the hosts' GPUs next to the jobs, write a small script around [`jobwarden.live()`](#building-your-own-dashboard), keep it in the project, and make it the control's `command`. Since `live()` reads in the background, the script only has to redraw.
221
+
222
+ **Notifications:**
223
+
224
+ ```sh
225
+ jobwarden wait train --notify 'cmux notify --title "$JOBWARDEN_TITLE" --body "$JOBWARDEN_BODY"'
226
+ ```
227
+
228
+ Two limits come from how the Dock works:
229
+
230
+ - **`dock.json` only seeds a Dock with no saved state.** A Dock that's already open won't pick up the control, so add it by hand: `cmux new-pane --placement dock --command "jobwarden watch --project demo"`.
231
+ - **A window's Dock is shared by its workspaces.** A project's Dock stays on screen when you switch to another project's workspace in that window.
232
+
233
+ ## How it works
234
+
235
+ ```
236
+ your machine host
237
+ ──────────── ────
238
+ jobwarden run ───── ssh ───────────────► jobrun ──► supervisor ──► your job
239
+ │ │
240
+ jobwarden watch ──┐ ▼ ▼
241
+ jobwarden wait ──┼─ one shared ssh ───► reads ~/.jobwarden/jobs/<name>/status.json, log
242
+ jobwarden.live() ─┘ connection
243
+ ```
244
+
245
+ `jobrun` is one file, copied to `~/.local/bin` on the host. `jobwarden run` warns if the copy is out of date. Each job gets a small supervisor that starts it, follows its log and writes `status.json`. Every reader then reads that file directly: nothing about jobs is saved on your machine.
246
+
247
+ All readers of a host share one SSH connection, every command has a hard timeout, and failed connections back off, so a dozen viewers don't look like an attack to a strict network. `docs/design-notes.md` explains the reasoning.
248
+
249
+ ### How the outcome is decided
250
+
251
+ When the job's process ends, the supervisor records the first match:
252
+
253
+ | What happened | State |
254
+ |:--|:--|
255
+ | Ended by `jobwarden stop` | `stopped` |
256
+ | Killed by a signal, or a non-zero exit code | `failed` |
257
+ | Exit 0, but ran less than `--min-runtime` | `failed` |
258
+ | Exit 0, but an `--expect` file is missing, empty or older than the start | `failed` |
259
+ | Otherwise | `done` |
260
+
261
+ If the supervisor's heartbeat goes quiet for 60 s, readers report `orphan` when the job's process is still alive, and `failed` with an unknown exit code when it's gone. A reboot looks like this too, even if the job had in fact finished. `jobwarden adopt <name>` starts a new supervisor for an `orphan`. It can't learn the exit code, so the job then counts as `done` only if its `--min-runtime` and `--expect` checks pass, and as `failed` if it has none.
262
+
263
+ ## What it doesn't catch
264
+
265
+ - **Wrong results with exit 0.** `--expect` only checks that a file exists, isn't empty and is new, and `--check` stops when the job does.
266
+ - **Healthy but silent jobs.** With no output for `--stall` minutes, `wait` reports `STALLED` and says no progress line was ever seen. Give such jobs a larger `--stall`.
267
+ - **Jobs started without jobwarden.** They hold no reservation, and only count as busy once `nvidia-smi` shows them.
268
+ - **Processes that escape.** `stop` can't reach a process that moved itself to its own session, or a container started by a script without `--name "$JOB_CONTAINER"`.
269
+ - **Containers killed from outside.** If the `docker run` client dies while its container carries on, the client's exit code is what gets recorded.
270
+
271
+ ## Development
272
+
273
+ ```sh
274
+ python3 -m unittest discover -s tests -t .
275
+ ```
276
+
277
+ The tests take about 2 minutes and use no network, real GPU or billable service: SSH is a stub or a throwaway `sshd` on localhost, and `HOME` is a temp folder.
278
+
279
+ `examples/project_dock.py` and `examples/job_config.py` also use `hoststats`, a separate package for CPU and GPU numbers that isn't published yet.
280
+
281
+ MIT licence: see `LICENSE`.
@@ -0,0 +1,46 @@
1
+ """What a project's script calls (stdlib only). Add this repo to sys.path, then:
2
+
3
+ import jobwarden
4
+ for j in jobwarden.jobs(project="demo"):
5
+ print(j["name"], j["state"], j["step"], j["total"], j["metrics"])
6
+
7
+ jobs() reads the hosts now, and returns when they have answered or timed out: a dead host can take about 40 s. A view that
8
+ must not wait uses live(), which reads in the background and always answers at once:
9
+
10
+ live = jobwarden.live(project="demo", interval=10)
11
+ while True:
12
+ draw(live.jobs(), live.down())
13
+ time.sleep(2)
14
+
15
+ Each dict has name, host, project, state (running, queued,
16
+ orphan, unreachable, unreadable, done, failed, stopped), step, total, frac, stage, eta_s, metrics (a dict), updated, last_at, note,
17
+ summary, gpu (the reserved card's short name),
18
+ start_at, ended_at, unreachable_since, temporary, cleared, status (its status.json, empty until read) and log (last lines).
19
+ Every job has all of them. The same data is in `jobwarden status --json`. down() says which hosts didn't answer.
20
+ """
21
+
22
+ from .core import UnknownHost, Unreachable, ssh_command
23
+ from .reader import Live, Reader, all_hosts
24
+
25
+ __all__ = ["UnknownHost", "Unreachable", "down", "hosts", "jobs", "live", "ssh_command"]
26
+ _reader = Reader()
27
+
28
+
29
+ def jobs(project=None, include_hidden=False, host=None):
30
+ """The jobs to show, read now, from all hosts or from one."""
31
+ return _reader.jobs(project, include_hidden, host)
32
+
33
+
34
+ def live(project=None, interval=10, host=None):
35
+ """Jobs read in the background every `interval` seconds: .jobs() and .down() never wait."""
36
+ return Live(project, interval, host)
37
+
38
+
39
+ def down():
40
+ """{host: why} for the hosts that didn't answer the last read."""
41
+ return _reader.down()
42
+
43
+
44
+ def hosts():
45
+ """The labels of the hosts jobs() reads: the project's hosts file, and "local" for this machine."""
46
+ return list(all_hosts())
@@ -0,0 +1,3 @@
1
+ from jobwarden.cli import main
2
+
3
+ main()