ppkit 0.1.0__py3-none-any.whl

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.
pp/lib/jobq.py ADDED
@@ -0,0 +1,3106 @@
1
+ """The job-queue engine behind `pp queue`, and its CLI (run_cli).
2
+
3
+ Named queues, one directory each: ~/.local/state/pp/queue/<name>/
4
+ jobs/<id>.json one file per job, holding every field, its `state` among them (queued, held, running, done,
5
+ failed): a change of state replaces that one file, and nothing else moves
6
+ jobs/<id>.run the claim one run of that job holds, from its own wrapper, for exactly as long as it runs
7
+ rc/<id>.<attempt>.<token> the exit code, written by the job's own `sh` under the name its record names,
8
+ so ANY worker can tell how it ended, and no attempt can read another's code
9
+ logs/<id>.log hooks/<id>-<event>.log worker.lock (the worker's claim, and its record) worker.log
10
+ FORMAT the number this layout is, written by the first writer that reaches the queue: a queue
11
+ whose jobs another pp wrote is refused where it is opened (open_queue), never read
12
+ and, shared by every queue, ~/.local/state/pp/queue/{counter,.lock,probes/,watchers/}: job ids are global (an id
13
+ names one job machine-wide), and the shared resource pools ([resources]) count every queue's running jobs.
14
+
15
+ One worker per queue reaps ended jobs, carries out the stops asked of them, re-queues retries, starts what
16
+ fits, and fires the job's on_fail / on_done hook on its FINAL outcome. Admission is strict head-of-line by
17
+ priority (a big job is never starved by small ones) unless the worker runs with backfill; a job waiting out its
18
+ retry delay is skipped, not waited for.
19
+
20
+ A queue's worker is the one actor that signals that queue's jobs, and only a worker ever holds the queue's
21
+ worker claim. `pp queue kill` writes a stop on the job ({by, t, grace}) and waits for the record of how the job
22
+ ended. It makes sure the queue has a worker: it wakes the one that holds the claim, or starts one where nobody
23
+ does. A timeout is the worker writing a stop on the job itself. So a kill, a timeout and a reap never cross,
24
+ and one actor records how a job ended.
25
+
26
+ One construct makes sure a queue has a worker and says what that worker is (`worker_up`, and `worker_now` for a
27
+ verb that only reports). It is the one place that reads the claim's holder. Every verb prints from the one
28
+ answer it gets back, so no verb acts on a look it took outside the scope that acted.
29
+
30
+ One function says what is to be done about a running job: `decide(job, facts)`, from the job's record and the
31
+ facts read with it, giving back the one action to carry out. It is pure. `Worker.carry_out` is the one pass
32
+ over the queue's running jobs that gathers those facts, asks it, and carries the action out. So a timeout, a
33
+ kill and a reap are facts about one job and one decision made from all of them.
34
+
35
+ Being alive is holding a claim (pp/lib/lock.py), never a pid in a file: the queue's worker holds the claim on
36
+ worker.lock while it runs, one run of a job is held by that run's own wrapper, and one run of a probe is held by
37
+ whoever runs it. The kernel lets a claim go when its holder ends, however it ends, so a worker or a job that was
38
+ killed leaves nothing to clean up and nothing that reads as alive.
39
+
40
+ The caller's session identity (pp.lib.session) is recorded as the job's owner, so `wait --mine` finds the jobs a
41
+ session queued, and stripped from the worker, the jobs and the hooks.
42
+ """
43
+ from __future__ import annotations
44
+
45
+ import json
46
+ import os
47
+ import re
48
+ import secrets
49
+ import select
50
+ import shlex
51
+ import shutil
52
+ import signal
53
+ import sys
54
+ import time
55
+ import traceback
56
+ from contextlib import contextmanager
57
+ from dataclasses import dataclass
58
+ from datetime import datetime
59
+
60
+ from pp.lib import config, lock, paths, proc, session, ui
61
+
62
+ STATES = ("queued", "held", "running", "done", "failed")
63
+ LIVE = ("queued", "held", "running")
64
+ ENDED = ("done", "failed")
65
+ CAPTURED_ENV = ("PATH", "VIRTUAL_ENV", "CONDA_PREFIX", "CONDA_DEFAULT_ENV")
66
+ # What a job runs as. Its stdin is the job's run claim, which this `sh` holds for exactly as long as the attempt
67
+ # lives; it writes its own pid in there first, so the claim names it even if the worker that started it died
68
+ # before recording it. The command itself reads /dev/null, so neither it nor anything it leaves behind holds the
69
+ # claim: the attempt is over when this `sh` is over. The exit code goes to another name and is renamed into
70
+ # place, so whoever reads it finds a whole code or none.
71
+ WRAP = ('printf \'{"pid": %d}\' "$$" >&0; "$@" 0</dev/null; r=$?; '
72
+ 'echo "$r" > "$PP_QUEUE_RCFILE.part" && mv "$PP_QUEUE_RCFILE.part" "$PP_QUEUE_RCFILE"')
73
+ ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
74
+ LAUNCH_BANNER = re.compile(r"\[pp queue\] \S+ \S+ job \d+ \(") # the line _spawn writes before each attempt
75
+ NAME_RE = re.compile(r"[a-z0-9][a-z0-9_-]*")
76
+ RECORD = re.compile(r"\d+\.json") # what a job record is called: jobs/<id>.json, and nothing else is one
77
+ RESERVED = ("probes", "watchers") # directories beside the queues
78
+ FORMAT = 1 # what a queue's FORMAT file holds: the layout of everything under queue/<name>/
79
+ TIMEOUT_GRACE = 10.0 # seconds between a timeout's SIGTERM and its SIGKILL
80
+ WAKE = signal.SIGUSR1 # what a verb sends a worker to end its wait between ticks, so a stop is served now
81
+ STOP_PERIOD = 0.1 # how long a woken worker waits between ticks while a stop of its queue is in flight
82
+ KILL_POLL = 0.1 # how often `kill` looks for the outcome that is recorded
83
+ # how long `kill` waits for that outcome, past the grace it asked for. Every pass of that wait wakes the
84
+ # worker that is up, so its next tick is that pass. What this bounds is the job's own exit time, and not the
85
+ # queue's poll period, whatever that is set to. Where no worker is up, a pass starts a round of them and
86
+ # waits for each start in it to say it is ready, so such a pass can carry the wait past this bound by that
87
+ # round's ready waits; `kill` says how long it really waited.
88
+ KILL_WAIT = 120.0
89
+ # how often one process starts a worker again for a queue whose last round of starts brought none up
90
+ START_EVERY = 5.0
91
+ RETRY_DELAY = 30.0
92
+ RETRY_DELAY_CAP = 3600.0
93
+ STOP_WAIT = 300.0 # how long `worker stop` waits for a worker inside a slow tick
94
+ CLEAN_BATCH = 100 # job records `clean` removes in one transaction, so a reader never waits long
95
+
96
+ _PROG = "queue" # the command running the CLI: names it in messages and starts its workers
97
+
98
+
99
+ # ---- store ------------------------------------------------------------------------------------------
100
+ #
101
+ # One file per job: jobs/<id>.json, with every field in it, `state` among them. A change of state replaces that
102
+ # one file, so a reader sees the job as it was before the change or as it is after it, never in two states and
103
+ # never in none. Reading and writing a record goes through txn() and listing(), or through find() for one.
104
+
105
+ def _top(*sub: str, create: bool = True) -> str:
106
+ return paths.state_dir("queue", *sub, create=create)
107
+
108
+
109
+ def _qroot(q: str, *sub: str, create: bool = True) -> str:
110
+ return paths.state_dir("queue", q, *sub, create=create)
111
+
112
+
113
+ def queues() -> list[str]:
114
+ """Every queue that exists on disk, each one opened through open_queue."""
115
+ top = _top(create=False)
116
+ try:
117
+ names = os.listdir(top)
118
+ except OSError:
119
+ return []
120
+ found = sorted(n for n in names if NAME_RE.fullmatch(n) and os.path.isdir(os.path.join(top, n, "jobs")))
121
+ for n in found:
122
+ open_queue(n)
123
+ return found
124
+
125
+
126
+ def _format_file(q: str, create: bool = False) -> str:
127
+ return os.path.join(_qroot(q, create=create), "FORMAT")
128
+
129
+
130
+ def _format_of(q: str) -> int | None:
131
+ """The number in queue q's FORMAT file, or None when it holds none."""
132
+ try:
133
+ with open(_format_file(q)) as f:
134
+ return int(f.read().strip())
135
+ except (OSError, ValueError):
136
+ return None
137
+
138
+
139
+ def _write_format(q: str) -> None:
140
+ """Say what this queue's store looks like, where it does not say yet. Every writer that reaches a queue
141
+ does it (writing_queue), so a queue that exists names the format its records are in."""
142
+ path = _format_file(q, create=True)
143
+ if not os.path.exists(path):
144
+ paths.write_json(path, FORMAT)
145
+
146
+
147
+ def open_queue(q: str) -> str:
148
+ """Queue q's directory, with the format of its store checked. The one place a queue is opened, so the one
149
+ place that refuses a queue this pp cannot read.
150
+
151
+ Every path that opens a queue comes through here: queues() for each queue it finds, the CLI for a queue it
152
+ is given by name, and work() and `worker start` for the queue they fall back to. A queue whose jobs another
153
+ pp wrote is refused rather than read, since this pp would report records it cannot see as gone, and launch
154
+ jobs beside ones that are still running."""
155
+ qdir = _qroot(q, create=False)
156
+ got = _format_of(q)
157
+ if got == FORMAT:
158
+ return qdir
159
+ # a job is a record, and a record is jobs/<id>.json wherever under jobs/ it lies, so the walk goes down:
160
+ # a store laid out another way, with a directory per state, is refused like any other foreign format. A
161
+ # run's claim and the name a write stopped halfway leaves are not records, so a queue whose records have
162
+ # all been cleaned holds nothing this pp cannot read.
163
+ has_jobs = any(RECORD.fullmatch(n) for _d, _subs, names in os.walk(os.path.join(qdir, "jobs"))
164
+ for n in names)
165
+ if got is None and not has_jobs:
166
+ return qdir # no record yet: a writer says what format this queue is in (writing_queue)
167
+ says = f"its FORMAT file says {got}" if got is not None else "it has no FORMAT file"
168
+ ui.die(f"{_PROG}: queue '{q}' holds jobs this pp cannot read: {says}. This pp reads format {FORMAT}. "
169
+ f"Drain that queue with the pp that wrote it, or remove {qdir}.", 2)
170
+
171
+
172
+ def writing_queue(q: str) -> str:
173
+ """Queue q's directory, opened and ready to be written in: it exists, and it says what format its store is
174
+ in. Every writer that may be the one creating a queue's directory comes through here, so a queue that
175
+ exists says its format whether or not it holds a job."""
176
+ qdir = open_queue(q)
177
+ _write_format(q)
178
+ return qdir
179
+
180
+
181
+ def _store_lock() -> str:
182
+ return os.path.join(_top(), ".lock")
183
+
184
+
185
+ def _counter_file() -> str:
186
+ return os.path.join(_top(), "counter")
187
+
188
+
189
+ def _jpath(q: str, jid: int, create: bool = False) -> str:
190
+ """Where job jid's record is in queue q. create: make the queue's directories, which only a writer does, so
191
+ a read of a job that is not there leaves nothing behind."""
192
+ return os.path.join(_qroot(q, "jobs", create=create), f"{jid}.json")
193
+
194
+
195
+ def _job_ids(q: str) -> list[int]:
196
+ """The ids in queue q, from one pass over its directory."""
197
+ try:
198
+ names = os.listdir(_qroot(q, "jobs", create=False))
199
+ except OSError:
200
+ return []
201
+ return sorted(int(n[:-5]) for n in names if RECORD.fullmatch(n))
202
+
203
+
204
+ def _read_job(q: str, jid: int) -> dict | None:
205
+ """Queue q's job jid, or None when it holds no readable record. Every write replaces the whole file, so a
206
+ read sees one whole record. The queue and the id come from the path, which names them."""
207
+ try:
208
+ with open(_jpath(q, jid)) as f:
209
+ j = json.load(f)
210
+ except (OSError, ValueError):
211
+ return None
212
+ if not isinstance(j, dict) or j.get("state") not in STATES:
213
+ return None
214
+ j.update(id=jid, queue=q)
215
+ return j
216
+
217
+
218
+ def _job_json(job: dict) -> dict:
219
+ return {k: v for k, v in job.items() if not k.startswith("_")}
220
+
221
+
222
+ def _write_job(job: dict) -> None:
223
+ paths.write_json(_jpath(job["queue"], job["id"], create=True), _job_json(job))
224
+
225
+
226
+ def _stage_job(job: dict, path: str) -> None:
227
+ """The whole record, written under a name no other writer picks, ready to be linked to the name that takes
228
+ its id."""
229
+ paths.write_json(path, _job_json(job))
230
+
231
+
232
+ def _remove_job(job: dict) -> None:
233
+ os.remove(_jpath(job["queue"], job["id"]))
234
+
235
+
236
+ def _counter() -> int:
237
+ """Where to start looking for a free job id. Never the authority: a lost counter costs a few looks, never
238
+ an id handed out twice."""
239
+ try:
240
+ with open(_counter_file()) as f:
241
+ return int(f.read().strip())
242
+ except (OSError, ValueError):
243
+ return 0
244
+
245
+
246
+ class Txn:
247
+ """The store, for the length of one scope: the only way pp reads or writes a job record, apart from find().
248
+
249
+ txn() holds the store lock, so nothing else reads or writes a record while the scope runs; listing() holds
250
+ it shared, for a pass that writes nothing. Either way the scope hands out the records it read under the
251
+ lock and takes back only those, so a copy read outside it can never be written over what another actor
252
+ recorded meanwhile.
253
+
254
+ with txn() as s:
255
+ j = s.job(jid, must_be=("running",)) # that job in one of those states, else None
256
+ if j:
257
+ j.update(rc=0, state="done")
258
+ s.save(j) # one replace of one file
259
+
260
+ s.jobs(state=, queue=) lists records, s.add(record, state) stores a new one, s.remove(j) drops one. A verb
261
+ that reports the state it found asks without must_be and reads j["state"].
262
+ """
263
+
264
+ def __init__(self, write: bool):
265
+ self.write = write
266
+ self._mine: dict[int, dict] = {} # id -> the one record object this scope hands out for it
267
+ self._ids: dict[str, list[int]] = {} # queue -> its ids, for the queues this scope has read
268
+
269
+ # -- reading
270
+
271
+ def job(self, jid: int, must_be: tuple = ()) -> dict | None:
272
+ """The job with this id, in whichever queue it is. None when there is none, and when must_be names the
273
+ states it may be in and it is in another."""
274
+ j = self._mine.get(jid)
275
+ if j is None:
276
+ for q in queues():
277
+ j = self._keep(_read_job(q, jid))
278
+ if j is not None:
279
+ break
280
+ return None if j is None or (must_be and j["state"] not in must_be) else j
281
+
282
+ def jobs(self, state=(), queue=None) -> list[dict]:
283
+ """The jobs in these states (every state when state is empty), in run order (prio, id). One pass over
284
+ each queue's directory, so a job is listed once, whole, and in one state."""
285
+ want = (state,) if isinstance(state, str) else tuple(state)
286
+ qs = queues() if queue is None else [queue] if isinstance(queue, str) else list(queue)
287
+ out = [j for q in qs for j in (self._mine.get(i) for i in self._pass(q))
288
+ if j is not None and (not want or j["state"] in want)]
289
+ out.sort(key=lambda j: (j.get("prio", 10), j["id"]))
290
+ return out
291
+
292
+ # -- writing
293
+
294
+ def save(self, job: dict) -> None:
295
+ """Write the record back: one replace of its file."""
296
+ self._writing(job)
297
+ _write_job(job)
298
+
299
+ def remove(self, job: dict) -> None:
300
+ self._writing(job)
301
+ _remove_job(job)
302
+ self._mine.pop(job["id"], None)
303
+
304
+ def add(self, job: dict, state: str) -> dict:
305
+ """Store a new job of job["queue"], and hand it back with its id, state, log and added filled in.
306
+
307
+ The whole record is written under a name of this writer's own and then linked to jobs/<id>.json, which
308
+ fails if that name is there. So the id and the record that holds it land together: two adds at once
309
+ never choose the same id, and an add stopped in between leaves no job file, never an empty one.
310
+
311
+ An id names one job machine-wide, so one any queue holds is taken; `counter` says where to start
312
+ looking and is never the authority, so a lost one costs a few looks, never an id handed out twice."""
313
+ self._writing(job, new=True)
314
+ q = job["queue"]
315
+ writing_queue(q) # the queue is opened, so a record never lands in one this pp cannot read
316
+ others = [x for x in queues() if x != q]
317
+ tmp = paths.staging(_qroot(q, "jobs"), "job")
318
+ n = _counter()
319
+ try:
320
+ while True:
321
+ n += 1
322
+ if any(os.path.exists(_jpath(x, n)) for x in others):
323
+ continue
324
+ job.update(id=n, state=state, log=os.path.join(_qroot(q, "logs"), f"{n}.log"), added=time.time())
325
+ _stage_job(job, tmp)
326
+ try:
327
+ os.link(tmp, _jpath(q, n))
328
+ except FileExistsError:
329
+ continue
330
+ break
331
+ finally:
332
+ try:
333
+ os.remove(tmp)
334
+ except OSError:
335
+ pass
336
+ paths.write_json(_counter_file(), n)
337
+ self._mine[n] = job
338
+ if q in self._ids:
339
+ self._ids[q].append(n)
340
+ return job
341
+
342
+ # -- what the scope read, and what it may write
343
+
344
+ def _keep(self, j: dict | None) -> dict | None:
345
+ """One record object per id for the scope's lifetime: the one save() takes back."""
346
+ if j is None:
347
+ return None
348
+ return self._mine.setdefault(j["id"], j)
349
+
350
+ def _pass(self, q: str) -> list[int]:
351
+ """Queue q's ids, read once per scope."""
352
+ if q not in self._ids:
353
+ self._ids[q] = [jid for jid in _job_ids(q)
354
+ if jid in self._mine or self._keep(_read_job(q, jid)) is not None]
355
+ return self._ids[q]
356
+
357
+ def _writing(self, job: dict, new: bool = False) -> None:
358
+ if not self.write:
359
+ raise RuntimeError("the store is open for reading: a listing writes nothing")
360
+ if not new and self._mine.get(job.get("id")) is not job:
361
+ raise RuntimeError(f"job {job.get('id')}: this record did not come from this scope, so what it holds "
362
+ "may be older than the store; read it again with s.job()")
363
+
364
+
365
+ @contextmanager
366
+ def txn():
367
+ """The store to this scope alone: read records, and change them. Every write of a job file happens here.
368
+
369
+ The scope holds the store lock (one for all the queues), so no other pp process reads or writes a record
370
+ while it runs, and the signals `lock.deferring` holds back are delivered when it ends, so a change is
371
+ never left half made. Keep it short: readers wait for it, so nothing that can block for long (a probe,
372
+ nvidia-smi, starting a process) belongs inside one. `TestContracts` in tests/test_constructs.py reads the
373
+ source and fails on a call to one of those inside a scope of this.
374
+
375
+ One lock is taken inside it, and only this one: the claim on a job's run, which a worker takes before the
376
+ record says the job is running (Worker.launch). A claim is taken without waiting, so the scope stays as
377
+ short as the two calls it takes; the job itself starts after the scope ends."""
378
+ with lock.locked(_store_lock(), defer=True):
379
+ yield Txn(write=True)
380
+
381
+
382
+ @contextmanager
383
+ def listing():
384
+ """The store for reading: one pass over each queue's directory, with nothing writing meanwhile.
385
+
386
+ POSIX does not say what a directory read sees while entries in it are replaced, and a file system may
387
+ report one entry twice or skip it, so a listing holds the store lock shared: a writer waits for it. Other
388
+ pp processes list beside it; two threads of one pp take turns, since the lock helper keeps one open file
389
+ per lock. A txn inside one is refused, since it would have to let the lock go to take it alone, and what it
390
+ had read would then be older than the store."""
391
+ with lock.locked(_store_lock(), shared=True):
392
+ yield Txn(write=False)
393
+
394
+
395
+ def _jobs(state=(), queue=None) -> list[dict]:
396
+ """One listing, for a caller that needs one list of records and nothing else from it."""
397
+ with listing() as s:
398
+ return s.jobs(state, queue)
399
+
400
+
401
+ def find(jid: int) -> dict | None:
402
+ """The job with this id, in whichever queue it is, or None.
403
+
404
+ Takes no lock: a job lives in one file, and every write of it is one replace, so one read sees one whole
405
+ record. A caller that needs several records to agree with each other asks a listing for them together."""
406
+ for q in queues():
407
+ j = _read_job(q, jid)
408
+ if j is not None:
409
+ return j
410
+ return None
411
+
412
+
413
+ def _run_claim(q: str, jid: int) -> str:
414
+ """The file whose claim one run of job jid holds. Taking it is how a worker says it is starting that job,
415
+ and the job's own wrapper holds it from then on, so a held claim means a run of this job is live."""
416
+ return os.path.join(_qroot(q, "jobs"), f"{jid}.run")
417
+
418
+
419
+ def _new_rcfile(j: dict) -> str:
420
+ """The file this attempt of job j writes its exit code to. One name per attempt, chosen as the attempt
421
+ starts and kept on the record, so no attempt can read the code another wrote and nothing has to be removed
422
+ before a launch."""
423
+ return os.path.join(_qroot(j["queue"], "rc"), f"{j['id']}.{j.get('attempt', 1)}.{secrets.token_hex(4)}")
424
+
425
+
426
+ def _forget_job(s: Txn, job: dict) -> None:
427
+ """In a transaction: take a job's record away, and with it every file that belongs to that job alone.
428
+
429
+ The record goes first, so no reader finds a job whose files are gone. Then the exit code each attempt
430
+ wrote, the job's log, the log of each hook that ran for it, and the claim its runs held. Only files under
431
+ the queue's own directories, never one pp did not write. What `drop` and `clean` both take away, so
432
+ neither leaves a file behind that nothing names any more: once the record is gone, nothing names them."""
433
+ q, jid = job["queue"], job["id"]
434
+ files = [p for p in [job.get("rcfile")] + [h.get("rcfile") for h in job.get("history") or []]
435
+ if p and os.path.dirname(p) == _qroot(q, "rc", create=False)]
436
+ if job.get("log") and os.path.dirname(job["log"]) == _qroot(q, "logs", create=False):
437
+ files.append(job["log"])
438
+ # a hook's log is named after the job and the event, so both names are this job's whether or not the
439
+ # record got as far as naming them (a hook that could not start wrote its first line and no more)
440
+ files += [os.path.join(_qroot(q, "hooks", create=False), f"{jid}-{e}.log") for e in ("failed", "done")]
441
+ s.remove(job)
442
+ for path in files:
443
+ try:
444
+ os.remove(path)
445
+ except OSError:
446
+ pass
447
+ # its record is gone and its run is over, so nothing holds this claim and this id never takes that name again
448
+ lock.Claim.forget(_run_claim(q, jid))
449
+
450
+
451
+ def ended_on_its_own(job: dict) -> tuple[int, float] | None:
452
+ """(the exit code this attempt wrote, when it wrote it), or None when it wrote none. The one reader of an
453
+ attempt's exit code.
454
+
455
+ The job's own wrapper writes it, under the name the record names, after the command has returned and before
456
+ the wrapper exits. So a code means the command ran to its end, whatever else was on its way, and its time
457
+ is when the job really ended, however late a worker reaps it. A signal that ends the wrapper leaves no code.
458
+ Both come from one open file, so the code and its time are the same attempt's."""
459
+ path = job.get("rcfile")
460
+ if not path:
461
+ return None
462
+ try:
463
+ with open(path) as f:
464
+ return int(f.read().strip()), os.fstat(f.fileno()).st_mtime
465
+ except (OSError, ValueError):
466
+ return None
467
+
468
+
469
+ def _finish(s: Txn, job: dict, state: str, rc: int | None, reason: str | None = None,
470
+ ended: float | None = None, not_retried: str | None = None, hook: str | None = None):
471
+ """In a transaction: the job is over, with this outcome. `state` is what it ended as, which `decide` says
472
+ and nothing works out again.
473
+
474
+ The outcome and the hook it owes are written together (`hook_due`), and that field is cleared once the
475
+ hook has run (`Worker.fire`). So a worker that dies between the two leaves a record that says a hook is
476
+ owed, and the next pass over the queue fires it: a hook fires once, and not never."""
477
+ now = ended or time.time()
478
+ job.update(ended=now, seconds=now - (job.get("started") or now), rc=rc, state=state)
479
+ if reason:
480
+ job["reason"] = reason
481
+ if not_retried:
482
+ job["not_retried"] = not_retried
483
+ if hook:
484
+ job["hook_due"] = hook
485
+ s.save(job)
486
+
487
+
488
+ def _requeue(s: Txn, job: dict, a: Action) -> str:
489
+ """In a transaction: the job goes back to its queue for the attempt the action names, with the same id.
490
+ -> the line for its log, which the caller appends after the scope."""
491
+ attempt, retries = int(job.get("attempt", 1)), int(job.get("retries") or 0)
492
+ job.setdefault("history", []).append({"attempt": attempt, "rc": a.rc, "reason": a.reason,
493
+ "started": job.get("started"), "ended": a.ended,
494
+ "seconds": a.ended - (job.get("started") or a.ended),
495
+ "rcfile": job.get("rcfile")})
496
+ for k in ("pid", "pgid", "pstart", "boot", "started", "gpu", "stop", "rcfile"):
497
+ job.pop(k, None) # this attempt's, including the file it wrote its code to
498
+ job.update(attempt=a.attempt, not_before=a.not_before, state="queued")
499
+ s.save(job)
500
+ return (f"[pp queue] {datetime.now():%Y-%m-%d %H:%M:%S} attempt {attempt}/{retries + 1} after {a.why}"
501
+ f"; attempt {a.attempt} in {ui.human_duration(a.not_before - a.ended)}")
502
+
503
+
504
+ def _group_handle(j: dict) -> proc.Handle | None:
505
+ """The job's process group, as the handle every signal to it goes through: the group id, the start time
506
+ recorded with it, and the boot both were read in.
507
+
508
+ None while the job has no process yet, for a record that holds no start time, and for a record from
509
+ another boot, which names no process here. `Worker._signal_group` then falls back to the pid in the job's
510
+ run claim, which is held by a live process and so is always this boot's."""
511
+ if j.get("boot") != proc.boot_id():
512
+ return None
513
+ return proc.Handle.recorded(j.get("pgid"), j.get("pstart"), group=True)
514
+
515
+
516
+ def _by() -> str:
517
+ """Who asked, as a record names them: the calling agent session, or the user."""
518
+ who = session.caller()
519
+ return who["session"] if who else "user"
520
+
521
+
522
+ def add_note(jid: int, text: str, by: str | None = None) -> dict | None:
523
+ """Append a note to a job in any state; the updated job, or None when there is no such job."""
524
+ if by is None:
525
+ by = _by()
526
+ with txn() as s:
527
+ j = s.job(jid)
528
+ if not j:
529
+ return None
530
+ j.setdefault("notes", []).append({"t": time.time(), "text": text, "by": by})
531
+ s.save(j)
532
+ return j
533
+
534
+
535
+ # ---- durations, small parsing ------------------------------------------------------------------------
536
+
537
+ parse_duration = config.parse_duration
538
+
539
+
540
+ def _dur_arg(v, what: str, default_unit: str = "s") -> float | None:
541
+ try:
542
+ return parse_duration(v, default_unit)
543
+ except ValueError as e:
544
+ ui.die(f"{_PROG}: {what}: {e}", 2)
545
+
546
+
547
+ def _num(v) -> int | float:
548
+ f = float(v)
549
+ return int(f) if f.is_integer() else f
550
+
551
+
552
+ def _hook_spec(v) -> str | None:
553
+ v = (v or "").strip() if isinstance(v, str) else v
554
+ return None if not v or v == "none" else str(v)
555
+
556
+
557
+ def hook_argv(spec: str | None) -> list[str] | None:
558
+ """argv a hook runs as: a shell command."""
559
+ spec = _hook_spec(spec)
560
+ return None if spec is None else ["/bin/sh", "-c", spec]
561
+
562
+
563
+ # ---- resources ------------------------------------------------------------------------------------
564
+ #
565
+ # The pool model as users see it (shared and per-queue pools, probes, settle) is `pp help queue`'s "Resources"; the
566
+ # defaults below are the ones it states. Here: a job's needs are a dict NAME -> amount; Resources.check asks every pool
567
+ # that provides a NAME, a probe's reading is cached in probes/ for `every`, and jobs launched less than `settle`
568
+ # before a reading are subtracted from it.
569
+
570
+ SHARED_DEFAULTS = {"cpu": "auto", "mem_gb": "auto", "gpu_gb": "auto"}
571
+ QUEUE_DEFAULTS = {"slots": 3}
572
+ POOL_KEYS = ("max", "probe", "every", "settle")
573
+ PROBE_EVERY = 30.0
574
+ PROBE_WAIT = 60.0 # how long a probe may take before pp ends it and its reading says so
575
+ SETTLE = 30.0 # a reading (nvidia-smi, a probe) may not show a job launched less than this before it
576
+
577
+
578
+ QUEUE_KEYS = ("timeout", "retries", "retry_delay", "retry_if", "on_fail", "on_done", "idle_exit", "poll", "backfill",
579
+ "resources")
580
+
581
+
582
+ def _qcfg(q: str, empty_ok: bool = True) -> dict:
583
+ """[queue.q], checked: a typo or a bad value is a ConfigError naming it, never a silent default or a traceback.
584
+ Durations come back in seconds. empty_ok False: what a launch reads (config.load)."""
585
+ t = config.load("queue", empty_ok).get(q, {})
586
+ if not isinstance(t, dict):
587
+ raise config.ConfigError(f"[queue.{q}] must be a table")
588
+ t = dict(t)
589
+ where = f"[queue.{q}]"
590
+ config.check_keys(t, where, QUEUE_KEYS)
591
+ for k in ("timeout", "retry_delay", "idle_exit", "poll"):
592
+ if k in t:
593
+ try:
594
+ t[k] = parse_duration(t[k])
595
+ except ValueError as e:
596
+ raise config.ConfigError(f"{where} {k}: {e}") from None
597
+ if "retries" in t and (isinstance(t["retries"], bool) or not isinstance(t["retries"], int) or t["retries"] < 0):
598
+ raise config.ConfigError(f"{where} retries = {t['retries']!r}: a whole number >= 0")
599
+ if "backfill" in t and not isinstance(t["backfill"], bool):
600
+ raise config.ConfigError(f"{where} backfill = {t['backfill']!r}: true or false")
601
+ for k in ("retry_if", "on_fail", "on_done"):
602
+ if k in t and not isinstance(t[k], str):
603
+ raise config.ConfigError(f"{where} {k} = {t[k]!r}: a string")
604
+ if t.get("retry_if"):
605
+ try:
606
+ re.compile(t["retry_if"])
607
+ except re.error as e:
608
+ raise config.ConfigError(f"{where} retry_if {t['retry_if']!r}: {e}") from None
609
+ return t
610
+
611
+
612
+ def _mem_total_gb() -> float:
613
+ try:
614
+ with open("/proc/meminfo") as f:
615
+ for line in f:
616
+ if line.startswith("MemTotal:"):
617
+ return int(line.split()[1]) / 1024 / 1024
618
+ except OSError:
619
+ pass
620
+ return 0.0
621
+
622
+
623
+ CARDS_WHY = "" # why the last _cards() gave None, for the messages that report it ("" when it had no card)
624
+
625
+
626
+ def _cards() -> list[dict] | None:
627
+ """[{index, name, total_gb, used_gb, free_gb, util}] from nvidia-smi; None when unreadable. Tests patch this."""
628
+ global CARDS_WHY
629
+ CARDS_WHY = ""
630
+ try:
631
+ out = proc.run(["nvidia-smi", "--query-gpu=index,name,memory.total,memory.used,utilization.gpu",
632
+ "--format=csv,noheader,nounits"], group="own", stdin="none", env="inherit", timeout=10)
633
+ except FileNotFoundError:
634
+ CARDS_WHY = "nvidia-smi is not installed"
635
+ return None
636
+ except OSError as e:
637
+ CARDS_WHY = f"nvidia-smi: {e}"
638
+ return None
639
+ if out.timed_out:
640
+ CARDS_WHY = "nvidia-smi did not answer in 10s"
641
+ return None
642
+ if out.returncode != 0:
643
+ said = (out.stderr.strip() or out.stdout.strip()).splitlines()
644
+ CARDS_WHY = f"nvidia-smi exited {out.returncode}" + (f": {said[-1][:120]}" if said else "")
645
+ return None
646
+ cards = []
647
+ for line in out.stdout.strip().splitlines():
648
+ parts = [x.strip() for x in line.split(",")]
649
+ try: # the name may hold commas: index first, the three numbers last
650
+ total, used = float(parts[-3]) / 1024, float(parts[-2]) / 1024
651
+ cards.append({"index": int(parts[0]), "name": ",".join(parts[1:-3]), "total_gb": total,
652
+ "used_gb": used, "free_gb": total - used, "util": parts[-1]})
653
+ except (ValueError, IndexError):
654
+ continue
655
+ return cards or None
656
+
657
+
658
+ def _pool(name: str, v, scope: str | None) -> dict | None:
659
+ where = (f"[queue.{scope}.resources] " if scope else "[resources] ") + name
660
+ if v is False: # cpu = false: no such pool here
661
+ return None
662
+ t = dict(v) if isinstance(v, dict) else {"max": v}
663
+ bad = sorted(set(t) - set(POOL_KEYS))
664
+ if bad:
665
+ raise config.ConfigError(f"{where}: unknown {', '.join(bad)} (a table takes {', '.join(POOL_KEYS)})")
666
+ p = {"name": name, "scope": scope, "where": where, "max": None, "probe": None, "every": PROBE_EVERY,
667
+ "settle": SETTLE}
668
+ mx = t.get("max")
669
+ if mx == "auto":
670
+ if name not in SHARED_DEFAULTS:
671
+ raise config.ConfigError(f'{where} = "auto": only cpu, mem_gb and gpu_gb know their own size')
672
+ mx = {"cpu": os.cpu_count() or 1, "mem_gb": round(_mem_total_gb()), "gpu_gb": None}[name] # gpu: per card
673
+ elif mx is not None:
674
+ try:
675
+ mx = _num(mx)
676
+ except (TypeError, ValueError):
677
+ raise config.ConfigError(f"{where} = {mx!r}: not a number") from None
678
+ p["max"] = mx
679
+ if t.get("probe") is not None:
680
+ if name == "gpu_gb":
681
+ raise config.ConfigError(f"{where}: gpu_gb is probed with nvidia-smi already")
682
+ if not isinstance(t["probe"], str) or not t["probe"].strip():
683
+ raise config.ConfigError(f"{where}: probe is a shell command that prints the amount free")
684
+ p["probe"] = t["probe"]
685
+ for k in ("every", "settle"):
686
+ if k in t:
687
+ try:
688
+ p[k] = parse_duration(t[k]) or 0.0
689
+ except ValueError as e:
690
+ raise config.ConfigError(f"{where} {k}: {e}") from None
691
+ if p["max"] is None and p["probe"] is None and name != "gpu_gb":
692
+ raise config.ConfigError(f"{where}: a number, or a table with max and/or probe")
693
+ return p
694
+
695
+
696
+ def pools(q: str, empty_ok: bool = True) -> list[dict]:
697
+ """The pools queue q's jobs draw from: its own ([queue.q.resources]), then the shared ones ([resources])."""
698
+ own = _qcfg(q, empty_ok).get("resources", {})
699
+ if not isinstance(own, dict):
700
+ raise config.ConfigError(f"[queue.{q}.resources] must be a table")
701
+ own = {**QUEUE_DEFAULTS, **own}
702
+ shared = {**SHARED_DEFAULTS, **config.load("resources", empty_ok)}
703
+ return [p for p in [_pool(k, v, q) for k, v in own.items()] + [_pool(k, v, None) for k, v in shared.items()] if p]
704
+
705
+
706
+ def _probe_path(p: dict) -> str:
707
+ return os.path.join(_top("probes"), f"{p['scope'] or '_shared'}.{p['name']}.json")
708
+
709
+
710
+ def _probe_claim(p: dict) -> str:
711
+ return os.path.join(_top("probes"), f"{p['scope'] or '_shared'}.{p['name']}.lock")
712
+
713
+
714
+ def _cached(path: str) -> dict | None:
715
+ try:
716
+ with open(path) as f:
717
+ return json.load(f)
718
+ except (OSError, json.JSONDecodeError):
719
+ return None
720
+
721
+
722
+ def reading(p: dict, run: bool = True) -> dict | None:
723
+ """A probe pool's latest reading {t, value, error}: the cached one while younger than `every`, which every
724
+ worker and `status` share. Where that one is older, a fresh reading, which only `run` asks for.
725
+
726
+ One run of a probe at a time: whoever takes the probe's claim runs it, and anyone else uses the reading
727
+ that is there, which is what a cached reading is for. Nothing waits for a probe (they may take a minute),
728
+ and the user's command is never run twice over at once.
729
+
730
+ Where another holder is running the first reading there is, this gives back {t, value: None, running: True}:
731
+ a job waits for that reading, and nobody is told the probe failed."""
732
+ path = _probe_path(p)
733
+ r = _cached(path)
734
+ if not run or (r and time.time() - float(r.get("t", 0)) < p["every"]):
735
+ return r
736
+ claim = lock.Claim.take(_probe_claim(p))
737
+ if claim is None:
738
+ # someone is running it now: this reading is the one they are replacing, and there may be none yet
739
+ return r or {"t": time.time(), "value": None, "running": True}
740
+ with claim:
741
+ t = time.time()
742
+ try:
743
+ # its own session, so the probe's own children end with it when its time runs out
744
+ out = proc.run(["/bin/sh", "-c", p["probe"]], group="own", stdin="none", env="scrubbed",
745
+ cwd=os.path.expanduser("~"), timeout=PROBE_WAIT)
746
+ except OSError as e:
747
+ r = {"t": t, "value": None, "error": str(e)[:160]}
748
+ else:
749
+ m = re.search(r"-?\d+(?:\.\d+)?", out.stdout)
750
+ if out.timed_out:
751
+ r = {"t": t, "value": None, "error": f"it did not answer in {PROBE_WAIT:g}s"}
752
+ elif out.returncode == 0 and m:
753
+ r = {"t": t, "value": _num(m[0]), "error": None}
754
+ else:
755
+ said = (out.stderr.strip() or out.stdout.strip()).splitlines()
756
+ r = {"t": t, "value": None, "error": (f"exit {out.returncode}" if out.returncode else "printed no number")
757
+ + (f": {said[-1][:120]}" if said else "")}
758
+ paths.write_json(path, r)
759
+ return r
760
+
761
+
762
+ def _need(j: dict, k: str):
763
+ return (j.get("needs") or {}).get(k, 0)
764
+
765
+
766
+ class Resources:
767
+ """Queue q's pools with their live readings (nvidia-smi, probes), taken once and outside the store lock;
768
+ admit() then runs under the lock against every queue's running jobs."""
769
+
770
+ def __init__(self, q: str, needed=(), live: bool = True, empty_ok: bool = True):
771
+ self.q = q
772
+ self.pools = pools(q, empty_ok)
773
+ self.by_name: dict[str, list[dict]] = {}
774
+ for p in self.pools:
775
+ self.by_name.setdefault(p["name"], []).append(p)
776
+ needed = set(needed)
777
+ self.cards_t = time.time()
778
+ self.cards = _cards() if "gpu_gb" in needed and "gpu_gb" in self.by_name else None
779
+ self.readings = {(p["scope"], p["name"]): reading(p, run=live)
780
+ for p in self.pools if p["probe"] and p["name"] in needed}
781
+
782
+ def _in(self, p: dict, running: list[dict]) -> list[dict]:
783
+ return running if p["scope"] is None else [r for r in running if r["queue"] == p["scope"]]
784
+
785
+ def gpu_budget(self, p: dict, card: dict) -> float:
786
+ return p["max"] if p["max"] is not None else round(max(card["total_gb"] - 2, 0), 1)
787
+
788
+ def check(self, needs: dict) -> str | None:
789
+ """Why these needs can never fit (nothing provides one, or it exceeds a budget); None when they might."""
790
+ for k, v in needs.items():
791
+ ps = self.by_name.get(k)
792
+ if not ps:
793
+ return (f"nothing provides '{k}': set [resources] {k} (shared by every queue) or "
794
+ f"[queue.{self.q}.resources] {k} (this queue's own) in {config.global_path()}")
795
+ if k == "gpu_gb":
796
+ if not self.cards:
797
+ return "needs gpu_gb but nvidia-smi sees no GPU" + (f" ({CARDS_WHY})" if CARDS_WHY else "")
798
+ top = max(min(self.gpu_budget(p, c) for p in ps) for c in self.cards)
799
+ if v > top:
800
+ return f"needs gpu_gb={v:g} but no card's budget is that large ({top:g})"
801
+ continue
802
+ for p in ps:
803
+ if p["max"] is not None and v > p["max"]:
804
+ return f"needs {k}={v:g} but {p['where']} = {p['max']:g}"
805
+ return None
806
+
807
+ def admit(self, job: dict, running: list[dict]) -> tuple[bool, str, int | None]:
808
+ """(fits, why not, card). running = every queue's running jobs. `why` keeps its fluctuating detail after
809
+ ';' so the worker notes a change of reason only."""
810
+ needs = job.get("needs") or {}
811
+ for k, v in needs.items():
812
+ if k == "gpu_gb":
813
+ continue
814
+ ps = self.by_name.get(k)
815
+ if not ps:
816
+ return False, f"{k}: nothing provides it ([resources] or [queue.{self.q}.resources] {k})", None
817
+ for p in ps:
818
+ if p["max"] is not None:
819
+ used = sum(_need(r, k) for r in self._in(p, running))
820
+ if used + v > p["max"]:
821
+ return False, f"{k}: {p['where']}; {used:g}+{v:g} > {p['max']:g}", None
822
+ if p["probe"]:
823
+ r = self.readings.get((p["scope"], p["name"]))
824
+ if r and r.get("running"):
825
+ return False, f"{k}: its probe is being read ({p['where']})", None
826
+ if not r or r.get("value") is None:
827
+ return False, f"{k}: probe failed ({p['where']}); {(r or {}).get('error') or 'no reading'}", None
828
+ pending = sum(_need(x, k) for x in running if (x.get("started") or 0) + p["settle"] > r["t"])
829
+ free = r["value"] - pending
830
+ if v > free:
831
+ return False, (f"{k}: not free ({p['where']}); probe says {r['value']:g}"
832
+ + (f", {pending:g} of it launched since" if pending else "") + f", need {v:g}"), None
833
+ need = needs.get("gpu_gb", 0)
834
+ if not need:
835
+ return True, "", None
836
+ ps = self.by_name.get("gpu_gb")
837
+ if not ps:
838
+ return False, "gpu_gb: nothing provides it", None
839
+ if not self.cards:
840
+ return False, "gpu: nvidia-smi unreadable" + (f" ({CARDS_WHY})" if CARDS_WHY else ""), None
841
+ settle = max(p["settle"] for p in ps)
842
+ whys = []
843
+ for c in sorted(self.cards, key=lambda c: -c["free_gb"]):
844
+ i = c["index"]
845
+ on = [r for r in running if r.get("gpu") == i]
846
+ why = None
847
+ for p in ps:
848
+ reserved, budget = sum(_need(r, "gpu_gb") for r in self._in(p, on)), self.gpu_budget(p, c)
849
+ if reserved + need > budget:
850
+ why = f"card {i} reserved {reserved:g}+{need:g} > {budget:g} ({p['where']})"
851
+ break
852
+ if why is None:
853
+ # what the reading cannot see yet: jobs launched on this card less than `settle` before it
854
+ pending = sum(_need(r, "gpu_gb") for r in on if (r.get("started") or 0) + settle > self.cards_t)
855
+ if c["free_gb"] - pending < need:
856
+ why = f"card {i} free {c['free_gb'] - pending:.1f}" + (
857
+ f" ({pending:g} launched in the last {settle:g}s, not allocated yet)" if pending else "")
858
+ if why is None:
859
+ return True, "", i
860
+ whys.append(why)
861
+ return False, f"gpu_gb {need:g} does not fit on any card; {', '.join(sorted(whys))}", None
862
+
863
+ def usage(self, running: list[dict], scope: str | None) -> list[dict]:
864
+ """The pools of one scope (a queue name, or None = shared) with what running jobs hold: for status."""
865
+ out = []
866
+ for p in self.pools:
867
+ if p["scope"] != scope or p["name"] == "gpu_gb":
868
+ continue
869
+ r = reading(p, run=False) if p["probe"] else None
870
+ out.append({"name": p["name"], "max": p["max"], "used": sum(_need(j, p["name"]) for j in self._in(p, running)),
871
+ "probe": p["probe"], "reading": r})
872
+ return out
873
+
874
+
875
+ # ---- how a job ends: one decision ------------------------------------------------------------------
876
+ #
877
+ # Five things can be true of a running job at once: a run of it is live or over, it wrote an exit code or it
878
+ # did not, a stop is on its record or there is none, the grace that stop was given is up or it is not, and its
879
+ # own time has run out or it has not. `decide` is the one place that says what is to be done about it, from all
880
+ # of them together. It reads facts and the job's record, and writes nothing: no clock, no store, no process,
881
+ # which `TestContracts` in tests/test_constructs.py reads the source to keep.
882
+ #
883
+ # Around it, `Worker.carry_out` gathers the facts about each running job, calls this once, and carries the one
884
+ # action out: the signal through the job's Handle, the record in one transaction, the hook after it. So a
885
+ # timeout, a kill and a reap are not three actors with three views of one job; they are facts about it, and one
886
+ # decision made from all of them.
887
+
888
+ NOTHING = "nothing" # leave it as it is
889
+ TIME_UP = "time-up" # write a timeout's stop on it; from there it is a stop like any other
890
+ TERM = "term" # SIGTERM to its group, recorded as the stop's `sent`
891
+ KILL9 = "kill9" # SIGKILL to its group, recorded as the stop's `kill9`
892
+ ENDED_AS = "ended-as" # record how it ended, and the hook that is owed for it
893
+ AGAIN = "again" # back to its queue for another attempt
894
+
895
+
896
+ @dataclass(frozen=True)
897
+ class Facts:
898
+ """What is true of one running job apart from its record, read before the decision and never inside it.
899
+
900
+ held a run of this attempt is live: its own wrapper holds the job's run claim. While it is held the
901
+ attempt's exit code counts for nothing, since the command itself can write that file ($PP_QUEUE_RCFILE
902
+ is in its environment); a code is the command's last word only once the run is over.
903
+ rc, rc_at the exit code this attempt wrote and when it wrote it (`ended_on_its_own`), or None for both.
904
+ sig the signal the attempt's process died of, where this worker started it and so holds the child.
905
+ now the time the facts were read at, which is the only clock the decision has.
906
+ tail the job's log as --retry-if matches against it, read where the job asks for one.
907
+ """
908
+
909
+ held: bool
910
+ rc: int | None = None
911
+ rc_at: float | None = None
912
+ sig: int | None = None
913
+ now: float = 0.0
914
+ tail: str = ""
915
+
916
+
917
+ @dataclass(frozen=True)
918
+ class Action:
919
+ """The one thing to be done about a job. `do` is which, and a field a kind does not use is None.
920
+
921
+ nothing leave it alone
922
+ time-up t, grace write {by: timeout, timeout: True, t, grace} on it
923
+ term SIGTERM to its group
924
+ kill9 SIGKILL to its group
925
+ ended-as state, rc, reason, hook, ended, sweep, not_retried record how it ended
926
+ again attempt, not_before, rc, reason, ended, sweep back to its queue for another attempt
927
+
928
+ hook is the event whose hook is owed ("failed", "done"), or None where the outcome owes none or the job
929
+ carries no such hook. sweep says anything left in the job's process group goes with it.
930
+ """
931
+
932
+ do: str
933
+ state: str | None = None
934
+ rc: int | None = None
935
+ reason: str | None = None
936
+ hook: str | None = None
937
+ ended: float | None = None
938
+ sweep: bool = False
939
+ attempt: int | None = None
940
+ not_before: float | None = None
941
+ not_retried: str | None = None
942
+ t: float | None = None
943
+ grace: float | None = None
944
+
945
+ @property
946
+ def why(self) -> str:
947
+ """The failure as the job's log names it and --retry-if matches it: a reason, or the exit code."""
948
+ return self.reason or f"exit {self.rc}"
949
+
950
+
951
+ def matches_retry_if(job: dict, why: str, tail: str) -> bool:
952
+ """Whether --retry-if matches this failure: its reason, or the tail of the job's log."""
953
+ pat = job.get("retry_if")
954
+ if not pat:
955
+ return True
956
+ try:
957
+ return re.search(pat, why + "\n" + tail, re.M) is not None # ^ $ per line
958
+ except re.error:
959
+ return False
960
+
961
+
962
+ def decide(job: dict, facts: Facts) -> Action:
963
+ """What is to be done about this running job: one action, from its record and the facts read with it.
964
+
965
+ The rules are the ones `pp help queue` states under "How a job ends", in the order they are tried here.
966
+ That help is their one statement; `TestTheDecision` in tests/test_queue.py is the table over every
967
+ combination of the facts, and it checks each of its rules against that help.
968
+
969
+ What a stop is does not rest on who asked for it: a timeout's carries `timeout`, so an asker called
970
+ `timeout` writes a kill's stop like anyone else.
971
+ """
972
+ stop = job.get("stop") or {}
973
+ asked = bool(stop) and not stop.get("timeout") # a kill: someone asked for this job to stop
974
+ out = bool(stop.get("sent")) or bool(stop.get("kill9")) # a signal of pp's went out for this stop
975
+ if facts.held:
976
+ # a run of it is live, so nothing is recorded about how it ended and its exit code says nothing yet:
977
+ # the one thing to do about it is to carry out the stop it carries, or to write a timeout's
978
+ if stop.get("denied"):
979
+ return Action(NOTHING) # pp was refused this job's group: nothing more goes out for it
980
+ if not stop:
981
+ due = (job.get("timeout") and job.get("started")
982
+ and facts.now - float(job["started"]) >= float(job["timeout"]))
983
+ return Action(TIME_UP, t=facts.now, grace=TIMEOUT_GRACE) if due else Action(NOTHING)
984
+ if stop.get("sent") is None:
985
+ return Action(TERM)
986
+ if not stop.get("kill9") and facts.now - float(stop["sent"]) >= float(stop.get("grace") or 0):
987
+ return Action(KILL9)
988
+ return Action(NOTHING) # signalled, and inside the grace it was given
989
+ # its run is over: how it ended is recorded here, and nothing of it is signalled again. A stop that was
990
+ # asked of it takes its group with it, so a command that held the signal back does not outlive the job.
991
+ sweep = bool(stop) and not stop.get("denied")
992
+ own = None if facts.rc is not None else (f"signal {facts.sig}" if facts.sig else "lost")
993
+ if asked and out:
994
+ # a kill's signal stopped it: "killed", and no hook, unless it wrote its own code as the kill arrived
995
+ return _ends(job, facts.rc, None if facts.rc is not None else "killed", facts.rc_at, sweep,
996
+ None if facts.rc is None else ("done" if facts.rc == 0 else "failed"))
997
+ if asked:
998
+ # a kill wrote the stop and no signal of pp's ever went out, so the job ended as it ended: that
999
+ # outcome, and the hook it owes. A job a kill has touched is never retried, whatever it had left
1000
+ return _ends(job, facts.rc, own, facts.rc_at, sweep, "done" if facts.rc == 0 else "failed")
1001
+ if facts.rc == 0:
1002
+ return _ends(job, 0, None, facts.rc_at, sweep, "done")
1003
+ if facts.rc is None and (out or stop.get("denied")):
1004
+ # a timeout's stop: pp's signal ended it, or pp was refused it and the attempt is over at its timeout
1005
+ return _or_again(job, facts, None, "timeout", None, sweep)
1006
+ # it ended on its own: the code it wrote, the signal it died of, or nothing at all
1007
+ return _or_again(job, facts, facts.rc, own, facts.rc_at, sweep)
1008
+
1009
+
1010
+ def _hook_due(job: dict, event: str | None) -> str | None:
1011
+ """The hook event this outcome owes, or None: the outcome owes none, or the job carries no such hook."""
1012
+ if event is None:
1013
+ return None
1014
+ return event if _hook_spec(job.get("on_fail" if event == "failed" else "on_done")) else None
1015
+
1016
+
1017
+ def _ends(job: dict, rc: int | None, reason: str | None, ended: float | None, sweep: bool, event: str | None,
1018
+ not_retried: str | None = None) -> Action:
1019
+ """The job is over: its state follows its exit code, and the hook that outcome owes is the job's own."""
1020
+ return Action(ENDED_AS, state="done" if rc == 0 and not reason else "failed", rc=rc, reason=reason,
1021
+ hook=_hook_due(job, event), ended=ended, sweep=sweep, not_retried=not_retried)
1022
+
1023
+
1024
+ def _or_again(job: dict, facts: Facts, rc: int | None, reason: str | None, ended: float | None,
1025
+ sweep: bool) -> Action:
1026
+ """A failure of the job's own: another attempt where it has one left and --retry-if matches it, else the
1027
+ end of it. A delay doubles per attempt, up to RETRY_DELAY_CAP."""
1028
+ attempt, retries = int(job.get("attempt", 1)), int(job.get("retries") or 0)
1029
+ why = reason or f"exit {rc}"
1030
+ left = attempt <= retries
1031
+ if left and matches_retry_if(job, why, facts.tail):
1032
+ now = ended or facts.now
1033
+ base = job.get("retry_delay")
1034
+ delay = min(float(RETRY_DELAY if base is None else base) * 2 ** (attempt - 1), RETRY_DELAY_CAP)
1035
+ return Action(AGAIN, rc=rc, reason=reason, ended=now, sweep=sweep, attempt=attempt + 1,
1036
+ not_before=now + delay)
1037
+ # attempts were left, but retry_if says this failure is not worth one
1038
+ nr = f"neither the reason ({why}) nor the log tail matches /{job.get('retry_if')}/" if left else None
1039
+ return _ends(job, rc, reason, ended, sweep, "failed", nr)
1040
+
1041
+
1042
+ def merge_stop(existing: dict | None, ask: dict) -> dict:
1043
+ """The one stop this run of the job carries, once this ask has landed on it. Pure: the caller names who
1044
+ asked and when.
1045
+
1046
+ One stop per run: what went out is all the decision and the waiting verb have to go on, so a second ask
1047
+ never writes over `t`, `sent` or `kill9`, and keeps only the shorter of the two graces, since a second ask
1048
+ hastens the SIGKILL and never delays it. It clears `denied`, so a signal pp was refused is asked for again.
1049
+
1050
+ A kill's ask on the stop a worker wrote for a timeout becomes the kill's: `by` is the asker, `timeout`
1051
+ goes, and the timeout stays in `was`. So a job a kill has touched is ended as a kill, never retried,
1052
+ however its stop began. A timeout landing on a kill's stop changes nothing: a kill stays a kill.
1053
+
1054
+ Which of the two an ask is comes from its `timeout` field and never from who asked, so an asker called
1055
+ `timeout` asks as a kill."""
1056
+ if not existing:
1057
+ return dict(ask)
1058
+ out = dict(existing)
1059
+ out["grace"] = min(float(existing.get("grace") or 0), float(ask.get("grace") or 0))
1060
+ out.pop("denied", None)
1061
+ if existing.get("timeout") and not ask.get("timeout"):
1062
+ out["was"], out["by"] = existing["by"], ask["by"]
1063
+ out.pop("timeout")
1064
+ return out
1065
+
1066
+
1067
+ # ---- worker ---------------------------------------------------------------------------------------
1068
+
1069
+ def _tail_start(f, n: int) -> int:
1070
+ """Where the last n lines of open binary file f begin (its end when n is 0), without reading all of it."""
1071
+ end = pos = f.seek(0, 2)
1072
+ buf = b""
1073
+ while n > 0 and pos > 0 and buf.count(b"\n") <= n:
1074
+ pos = max(0, pos - 65536)
1075
+ f.seek(pos)
1076
+ buf = f.read(end - pos)
1077
+ return end - sum(len(line) for line in buf.splitlines(keepends=True)[-n:]) if n > 0 else end
1078
+
1079
+
1080
+ def _tail(src, n: int) -> list[str]:
1081
+ """Last n lines of a file (a path, or a file open for binary reading) without reading all of it."""
1082
+ if isinstance(src, str):
1083
+ with open(src, "rb") as f:
1084
+ return _tail(f, n)
1085
+ src.seek(_tail_start(src, n))
1086
+ return src.read().decode(errors="replace").splitlines()[-n:] if n > 0 else []
1087
+
1088
+
1089
+ def default_tag(cmd: list[str]) -> str:
1090
+ """The tag of a job added without --tag."""
1091
+ return shlex.join(cmd)[:40]
1092
+
1093
+
1094
+ def _text(v, cut: int | None = None) -> str:
1095
+ """A job's own text as pp prints it: its tag, its command, its project, a note, a line of its log.
1096
+
1097
+ The one place a job's text is made printable: through ui.printable (CONTRIBUTING.md, The code), so an
1098
+ escape sequence a queued command put in it cannot redraw the terminal of whoever reads it, with tabs shown
1099
+ too, since one would break a table's columns. The cut comes after that, so no cut lands inside an
1100
+ escape."""
1101
+ out = ui.printable("" if v is None else str(v), tabs=False)
1102
+ return out if cut is None else out[:cut]
1103
+
1104
+
1105
+ def failure_tail(job: dict, n: int) -> list[str]:
1106
+ """The last n lines of a job's log as a person reads them: colors and \\r-overwritten progress gone, every
1107
+ other control character shown instead of acted on, long lines cut, and without the launch banner ("job N
1108
+ (q) in CWD: CMD"), which only repeats what was queued."""
1109
+ lines = [_text(ANSI.sub("", ln.rsplit("\r", 1)[-1]).rstrip()) for ln in log_tail(job, n * 3)
1110
+ if not LAUNCH_BANNER.match(ln)]
1111
+ return [ln[:300] for ln in lines if ln][-n:]
1112
+
1113
+
1114
+ def log_tail(job: dict, n: int = 40) -> list[str]:
1115
+ try:
1116
+ return _tail(job["log"], n) if job.get("log") else []
1117
+ except OSError:
1118
+ return []
1119
+
1120
+
1121
+ def _append_log(job: dict, line: str):
1122
+ try:
1123
+ os.makedirs(os.path.dirname(job["log"]), exist_ok=True)
1124
+ with open(job["log"], "a") as f:
1125
+ f.write(line.rstrip("\n") + "\n")
1126
+ except (OSError, KeyError):
1127
+ pass
1128
+
1129
+
1130
+ class Wait:
1131
+ """The wait a worker does between its ticks, and the one thing that shortens it. `waking()` hands one out.
1132
+
1133
+ `wait(seconds)` waits that long, ends the moment a signal arrives, and says whether one did. Nothing in a
1134
+ worker's loop sleeps: a stop, a Ctrl-C and a `worker stop` all reach it through the same wait."""
1135
+
1136
+ def __init__(self, fd: int):
1137
+ self.fd = fd
1138
+
1139
+ def wait(self, seconds: float) -> bool:
1140
+ """-> whether a signal ended the wait, rather than the time being up."""
1141
+ if self._took():
1142
+ return True # one arrived while the tick ran: it ends this wait, and is not waited out
1143
+ left = max(float(seconds), 0.0)
1144
+ if not left or not select.select([self.fd], [], [], left)[0]:
1145
+ return False
1146
+ self._took()
1147
+ return True
1148
+
1149
+ def _took(self) -> bool:
1150
+ """Take whatever the wakeup pipe holds. -> whether it held anything."""
1151
+ try:
1152
+ return bool(os.read(self.fd, 4096))
1153
+ except BlockingIOError:
1154
+ return False
1155
+
1156
+
1157
+ @contextmanager
1158
+ def waking():
1159
+ """The scope a worker's loop runs in: inside it, a signal ends the wait `Wait.wait` does, at once.
1160
+
1161
+ Python's own wakeup pipe is what ends it (signal.set_wakeup_fd): the C handler writes the number of every
1162
+ signal this process takes into that pipe, so a wake sent while a tick runs is a byte waiting in the pipe
1163
+ when the next wait begins, and no wake is lost. The Python handlers run as they always do, so the same
1164
+ SIGTERM that ends a wait sets the stop flag work() reads. The wake's own handler does nothing: it is there
1165
+ so that a wake cannot end a worker that is not waiting.
1166
+
1167
+ Only the main thread may hold the wakeup pipe, and one process has one: this is the scope of the worker in
1168
+ it, entered before it claims its queue, so a wake can never reach a worker that is not in this scope."""
1169
+ r, w = os.pipe()
1170
+ os.set_blocking(r, False)
1171
+ os.set_blocking(w, False)
1172
+ was_handler = signal.signal(WAKE, lambda *_: None)
1173
+ was_fd = signal.set_wakeup_fd(w, warn_on_full_buffer=False)
1174
+ try:
1175
+ yield Wait(r)
1176
+ finally:
1177
+ signal.set_wakeup_fd(was_fd)
1178
+ signal.signal(WAKE, was_handler)
1179
+ os.close(r)
1180
+ os.close(w)
1181
+
1182
+
1183
+ class Worker:
1184
+ """One queue's worker. One tick = one pass over its running jobs (`carry_out`: the facts about each, the
1185
+ decision, the action), then launch what fits. work() loops it; tests drive it tick by tick."""
1186
+
1187
+ def __init__(self, queue: str, backfill: bool = False, log=None):
1188
+ me = proc.read(os.getpid())
1189
+ self.queue = queue
1190
+ self.children: dict[int, proc.Child] = {} # jobs this worker launched; others are adopted
1191
+ self.hooks: list[proc.Child] = [] # kept only to reap them
1192
+ self.backfill = backfill
1193
+ self.pstart = me.start if me else None
1194
+ self.started = time.time()
1195
+ self.waiting: dict | None = None # {id, tag, why, t}: what the head of the queue waits for
1196
+ self.said = None # (id, reason) last logged, without the detail after ';'
1197
+ self.serving = 0 # stops of this queue in flight at the last tick: while any is, the wait is short
1198
+ self.claim: lock.Claim | None = None # where it records itself; work() sets it once it has taken it
1199
+ self.log = log or (lambda msg: None)
1200
+ self.stopping = lambda: False # work() points it at its stop flag: a stopping worker launches nothing new
1201
+
1202
+ def record(self) -> dict:
1203
+ """What this worker says about itself while it holds its claim, which is where `status` reads it."""
1204
+ return {"pid": os.getpid(), "pstart": self.pstart, "started": self.started, "backfill": self.backfill,
1205
+ "waiting": self.waiting}
1206
+
1207
+ def _publish(self):
1208
+ if self.claim is not None:
1209
+ self.claim.write(self.record())
1210
+
1211
+ def set_backfill(self, on: bool):
1212
+ self.backfill = on
1213
+ self._publish()
1214
+
1215
+ def tick(self):
1216
+ self.carry_out() # the one actor that signals this queue's jobs and records how one ended
1217
+ watchers() # and the leases of the waits a signal ended, swept where the queue is read
1218
+ self.hooks = [h for h in self.hooks if h.poll() is None]
1219
+ if not self.stopping():
1220
+ self.launch() # last: a broken config raises here, after ended jobs are recorded
1221
+
1222
+ # -- one pass over the running jobs: the facts, the decision, the action
1223
+
1224
+ def carry_out(self):
1225
+ """One pass over this queue's running jobs: gather the facts about each, ask `decide` once, and do the
1226
+ one thing it says. The whole of a tick apart from launching, and the worker's own: the worker is the
1227
+ one actor that signals this queue's jobs.
1228
+
1229
+ A timeout, a kill and a reap are not three passes over the queue with three views of a job. They are
1230
+ facts about one job and one decision made from all of them, so no part of it can be wrong about what
1231
+ another part did."""
1232
+ now = time.time()
1233
+ with listing() as s: # one pass: the jobs to decide about, and the hooks the queue still owes
1234
+ jobs = list(s.jobs(("running",) + ENDED, self.queue))
1235
+ owed = [j for j in jobs if j["state"] in ENDED and j.get("hook_due")]
1236
+ in_flight = 0
1237
+ for j in [x for x in jobs if x["state"] == "running"]:
1238
+ stop = j.get("stop") or {}
1239
+ action = self._step(j, self._facts(j, now))
1240
+ if action.do in (TERM, KILL9, TIME_UP) or (action.do == NOTHING and stop
1241
+ and not stop.get("denied") and not stop.get("kill9")):
1242
+ in_flight += 1 # a stop with something still to go out for it
1243
+ # while a stop is in flight, and a verb is waking this worker for it, the loop's wait is STOP_PERIOD
1244
+ # and not the poll (work()). A stop whose SIGKILL has gone out has nothing left to carry out, so it is
1245
+ # not one: the worker waits for the kernel to end a job that is slow to die, at its poll or at the
1246
+ # next wake of whoever is waiting, and the reap sees the run claim go like any other
1247
+ self.serving = in_flight
1248
+ for j in owed:
1249
+ self.fire(j, j["hook_due"]) # owed by a worker that did not get to fire it
1250
+ # a child of this worker's whose record no longer says running: something outside pp ended the job
1251
+ # while it was starting, and `_spawn` stopped the process it had started. Reap the zombie
1252
+ for jid, p in list(self.children.items()):
1253
+ if p.poll() is not None and (find(jid) or {}).get("state") != "running":
1254
+ del self.children[jid]
1255
+
1256
+ def _facts(self, j: dict, now: float) -> Facts:
1257
+ """What is true of this running job besides its record, all of it read here and nothing decided:
1258
+ whether a run of it is live, the exit code that run wrote and when, the signal its own child died of,
1259
+ and the log tail --retry-if matches against.
1260
+
1261
+ Read before the transaction, since none of it is store state and a log tail can be long."""
1262
+ held = lock.Claim.holder(_run_claim(self.queue, j["id"]))
1263
+ if held is not None:
1264
+ # a run of it is live, whatever its record holds: it is never lost, and never started again. Its
1265
+ # exit code is not read at all here: while the run is live it says nothing (Facts).
1266
+ self._adopt(j, held)
1267
+ return Facts(held=True, now=now)
1268
+ p = self.children.pop(j["id"], None)
1269
+ code = p.poll() if p is not None else None
1270
+ rc, ended = ended_on_its_own(j) or (None, None)
1271
+ tail = "\n".join(log_tail(j, 200)) if rc != 0 and j.get("retry_if") else ""
1272
+ return Facts(held=False, rc=rc, rc_at=ended, sig=-code if code is not None and code < 0 else None,
1273
+ now=now, tail=tail)
1274
+
1275
+ def _step(self, j: dict, facts: Facts) -> Action:
1276
+ """The one decision about one job, and the action it names, carried out: what is store state in one
1277
+ transaction, the signal through the job's Handle, the log line and the hook after the scope.
1278
+ -> the action, which says whether a stop of this job is still in flight.
1279
+
1280
+ The decision is made inside the transaction, on the record the store holds and the facts read before
1281
+ it: a stop another actor wrote since those facts were read is part of it, so a job a kill has touched
1282
+ is never retried past its stop."""
1283
+ jid = j["id"]
1284
+ line = None
1285
+ with txn() as s:
1286
+ cur = s.job(jid, must_be=("running",))
1287
+ if cur is None:
1288
+ return Action(NOTHING) # nothing else records a running job: there is nothing to record
1289
+ a = decide(cur, facts)
1290
+ if a.sweep:
1291
+ self._sweep_group(cur) # its leader is gone; anything left in its group goes too
1292
+ if a.do == TIME_UP:
1293
+ cur["stop"] = {"by": "timeout", "timeout": True, "t": a.t, "grace": a.grace}
1294
+ s.save(cur)
1295
+ elif a.do == AGAIN:
1296
+ line = _requeue(s, cur, a)
1297
+ elif a.do == ENDED_AS:
1298
+ _finish(s, cur, a.state, a.rc, a.reason, a.ended, a.not_retried, hook=a.hook)
1299
+ if a.do in (TERM, KILL9):
1300
+ self._send(cur, a)
1301
+ return a
1302
+ if line:
1303
+ _append_log(cur, line) # after the scope: a job's log is not the store
1304
+ if a.do == TIME_UP:
1305
+ self.log(f"timeout {jid} {cur.get('tag')} after {ui.human_duration(float(cur['timeout']))}: "
1306
+ "stopping it")
1307
+ elif a.do == AGAIN:
1308
+ self.log(f"retry {jid} {cur.get('tag')}: attempt {cur['attempt']}/{int(cur.get('retries') or 0) + 1} "
1309
+ f"in {_in_delay(cur['not_before'])}")
1310
+ elif a.do == ENDED_AS:
1311
+ why = f" ({cur['reason']})" if cur.get("reason") else ""
1312
+ self.log(f"{'done' if cur['state'] == 'done' else 'FAILED'} {jid} {cur.get('tag')} "
1313
+ f"rc={cur.get('rc')}{why} {ui.human_duration(cur.get('seconds'))}")
1314
+ if a.hook:
1315
+ self.fire(cur, a.hook)
1316
+ return a
1317
+
1318
+ def _send(self, job: dict, a: Action) -> None:
1319
+ """The signal one stop asks for next, and what it did on the record: a SIGTERM marked `sent`, a
1320
+ SIGKILL marked `kill9`, a refusal marked `denied`. The queue's worker is the one actor that signals a
1321
+ job's processes, so a kill, a timeout and a reap never cross.
1322
+
1323
+ The signal goes out first and is recorded after, so a record that says a signal went out is one that
1324
+ did, which is all the decision about this job has to go on."""
1325
+ stop = job.get("stop") or {}
1326
+ sig, mark, word = ((signal.SIGTERM, "sent", "SIGTERM to its group") if a.do == TERM else
1327
+ (signal.SIGKILL, "kill9",
1328
+ f"SIGKILL after {ui.human_duration(float(stop.get('grace') or 0))}"))
1329
+ outcome = self._signal_group(job, sig)
1330
+ if outcome is None or outcome == "gone":
1331
+ return # nothing to signal: its run is starting, or it is over and the next pass says how
1332
+ now = time.time()
1333
+ with txn() as s:
1334
+ cur = s.job(job["id"], must_be=("running",))
1335
+ if cur is None or not cur.get("stop"):
1336
+ return
1337
+ cur["stop"]["denied" if outcome == "denied" else mark] = now
1338
+ s.save(cur)
1339
+ self.log(f"stop {job['id']} {job.get('tag')} ({stop.get('by')}): "
1340
+ + (f"not allowed to signal its group {cur.get('pgid')}" if outcome == "denied" else word))
1341
+
1342
+ def _adopt(self, j: dict, held: dict):
1343
+ """Complete the record of a running job from its claim. The wrapper writes its own pid in there, so a
1344
+ job whose worker died between the start and the record is named again, and can be timed out, killed and
1345
+ reported like any other. The claim is held, so that pid is that wrapper's."""
1346
+ if j.get("pid") or not held.get("pid"):
1347
+ return
1348
+ p = proc.read(int(held["pid"]))
1349
+ if p is None:
1350
+ return # it ended as this was read; the next look reaps it
1351
+ with txn() as s:
1352
+ cur = s.job(j["id"], must_be=("running",))
1353
+ if cur is None or cur.get("pid"):
1354
+ return
1355
+ cur.update(pid=p.pid, pgid=p.pgid, pstart=p.start, boot=proc.boot_id())
1356
+ s.save(cur)
1357
+ self.log(f"adopted {j['id']} {j.get('tag')}: its run is pid {p.pid}, from its claim")
1358
+
1359
+ def fire(self, job: dict, event: str):
1360
+ """Run the job's on_fail / on_done hook, detached; record it on the job, and that the job no longer
1361
+ owes it.
1362
+
1363
+ The outcome and the hook owed for it are written in one transaction (`_finish`); `hook_due` is cleared
1364
+ here, once the hook has run. A hook that could not start is reported and cleared too, so a queue is
1365
+ never left trying to start it again every tick."""
1366
+ spec = _hook_spec(job.get("on_fail" if event == "failed" else "on_done"))
1367
+ argv = hook_argv(spec)
1368
+ q, jid = job["queue"], job["id"]
1369
+ hlog = os.path.join(_qroot(q, "hooks"), f"{jid}-{event}.log")
1370
+ ran = False
1371
+ if argv:
1372
+ env = {"PP_QUEUE": q, "PP_QUEUE_JOB": str(jid), "PP_QUEUE_EVENT": event,
1373
+ "PP_QUEUE_RC": "" if job.get("rc") is None else str(job["rc"]), "PP_QUEUE_REASON": job.get("reason") or (f"exit {job['rc']}" if event == "failed" and job.get("rc") is not None else ""),
1374
+ "PP_QUEUE_LOG": job.get("log") or "", "PP_QUEUE_CWD": job.get("cwd") or "", "PP_QUEUE_TAG": job.get("tag") or ""}
1375
+ cwd = job.get("cwd") if job.get("cwd") and os.path.isdir(job["cwd"]) else "/"
1376
+ try:
1377
+ with open(hlog, "a") as f:
1378
+ f.write(f"[pp queue] {datetime.now():%Y-%m-%d %H:%M:%S} {event} hook of job {jid}: {shlex.join(argv)}\n")
1379
+ f.flush()
1380
+ h = proc.spawn(argv, group="own", stdin="none", env="scrubbed", add_env=env, cwd=cwd, stdout=f,
1381
+ stderr=proc.STDOUT)
1382
+ self.hooks.append(h)
1383
+ ran = True
1384
+ except OSError as e:
1385
+ self.log(f"hook {event} of {jid} could not start: {e}")
1386
+ with txn() as s: # it has run: the job owes it no longer, whether or not it could start
1387
+ cur = s.job(jid)
1388
+ if cur is not None:
1389
+ if ran:
1390
+ cur.setdefault("hooks", []).append({"event": event, "cmd": spec, "t": time.time(), "log": hlog})
1391
+ if cur.pop("hook_due", None) is not None or ran:
1392
+ s.save(cur)
1393
+ if ran:
1394
+ self.log(f"hook {event} of {jid}: {spec} (log {hlog})")
1395
+
1396
+ # -- stopping a job: a `kill`, and a timeout, which is a stop the worker writes on the job itself
1397
+
1398
+ @staticmethod
1399
+ def wake(h: proc.Handle | None) -> None:
1400
+ """End the wait of the worker this handle names, so it ticks now instead of when its period is up. The
1401
+ handle comes from the look `worker_up` has already taken (`Holder.handle`), so waking a worker costs no
1402
+ second look at the claim.
1403
+
1404
+ The wake is a signal to a worker, never to a job: a worker inside `waking` takes it from its own
1405
+ wakeup pipe, so one sent while a tick runs ends the next wait rather than being lost. It is the
1406
+ worker's own, like the signalling above it, which is why it lives here and not in the verb that
1407
+ sends it. No verb holds the worker claim: a verb writes its stop, wakes the worker or starts one, and
1408
+ waits for the record."""
1409
+ if h is not None:
1410
+ proc.signal_pid(h, WAKE)
1411
+
1412
+ def _sweep_group(self, job: dict):
1413
+ """The leader of a stopped job's group is gone; anything left in that group goes too, so a command that
1414
+ held the signal back does not outlive the job."""
1415
+ self._signal_group(job, signal.SIGKILL)
1416
+
1417
+ def _signal_group(self, job: dict, sig: int) -> str | None:
1418
+ """Signal the job's process group, and say how that went (proc.signal_group's outcomes), or None while
1419
+ the job has no group to signal yet.
1420
+
1421
+ The record names the group with the start time read beside it, so a group id that belongs to another
1422
+ process by now is not signalled. Where the record names none, or names one without that start time, the
1423
+ job's claim names its wrapper, which leads that group and holds the claim, so that pid is still its own.
1424
+ One of the two names the group, unless the wrapper has not written its pid yet, which is the first
1425
+ moment of a launch."""
1426
+ h = _group_handle(job)
1427
+ if h is None:
1428
+ held = lock.Claim.holder(_run_claim(job["queue"], job["id"])) or {}
1429
+ h = proc.Handle.live(int(held["pid"]), group=True) if held.get("pid") else None
1430
+ if h is None:
1431
+ return None
1432
+ outcome = proc.signal_group(h, sig)
1433
+ if outcome == "gone":
1434
+ self.log(f"nothing to signal in job {job['id']}'s group {h.id}: it is gone")
1435
+ return outcome
1436
+
1437
+ # -- launching
1438
+
1439
+ def launch(self):
1440
+ queued = _jobs("queued", self.queue)
1441
+ now = time.time()
1442
+ ready = [j for j in queued if (j.get("not_before") or 0) <= now] # a retry's delay never blocks the line
1443
+ if not ready:
1444
+ self._note(None)
1445
+ return
1446
+ res = Resources(self.queue, {k for j in ready for k in (j.get("needs") or {})}, empty_ok=False)
1447
+ if self.stopping():
1448
+ return # told to stop while the readings were taken
1449
+ taken: list = [] # (record, card, claim) for each job this pass admitted
1450
+ blocked = None # the first job that did not fit, and why
1451
+ try:
1452
+ # one transaction for the whole pass: it reads the running jobs it admits against once, and reads
1453
+ # each job it is about to start again in that same scope, since another queue's worker may be
1454
+ # admitting from a shared pool and a verb may have changed the job since it was listed (prio, a note)
1455
+ with txn() as s:
1456
+ running = s.jobs("running")
1457
+ for j in ready:
1458
+ cur = s.job(j["id"], must_be=("queued",))
1459
+ if cur is None:
1460
+ continue # held, dropped or started meanwhile
1461
+ ok, why, card = res.admit(cur, running)
1462
+ if not ok:
1463
+ if blocked is None:
1464
+ blocked = (cur, why)
1465
+ if self.backfill:
1466
+ continue
1467
+ break
1468
+ # the run claim before the record says running, so a reader never sees a running job with
1469
+ # nothing holding its claim unless its run has ended. Taking one is two calls that never
1470
+ # wait, which is why it belongs in here; nothing under the store lock may wait either.
1471
+ claim = lock.Claim.take(_run_claim(self.queue, cur["id"]))
1472
+ if claim is None:
1473
+ continue # a run of this job is live already
1474
+ taken.append((cur, card, claim)) # before the rest of it, which can raise
1475
+ self._claim(s, cur, card)
1476
+ running.append(cur) # what this pass has taken counts against the next job in it
1477
+ for cur, card, claim in taken: # outside the transaction: starting a process is not store work
1478
+ self._spawn(cur, card, claim)
1479
+ finally:
1480
+ # pp's own copy of every claim this pass took, however it ended: a wrapper that was started holds
1481
+ # the claim through the file description it was given, so releasing this copy frees nothing of
1482
+ # its; one for a job no `_spawn` reached is freed here, and that job is reaped like any other
1483
+ # whose run is over. Releasing twice is not an error.
1484
+ for _cur, _card, claim in taken:
1485
+ claim.release()
1486
+ if blocked is None:
1487
+ self._note(None)
1488
+ else:
1489
+ self._note(*blocked)
1490
+
1491
+ @staticmethod
1492
+ def _claim(s: Txn, j: dict, gpu: int | None):
1493
+ """In a transaction: the job, as read in it, becomes running, with the file this attempt writes its exit
1494
+ code to. Its run claim is held before this, so no reader ever sees it running with nothing holding its
1495
+ claim unless its run has ended."""
1496
+ j.update(started=time.time(), gpu=gpu, state="running", rcfile=_new_rcfile(j))
1497
+ j.pop("not_before", None)
1498
+ s.save(j)
1499
+
1500
+ def _spawn(self, j: dict, gpu: int | None, claim: lock.Claim) -> dict | None:
1501
+ """Start the attempt's wrapper, and hand it the run claim as its stdin: it holds that claim for exactly
1502
+ as long as the attempt lives. pp closes its own copy here, so nothing but the wrapper holds it."""
1503
+ jid, q = j["id"], self.queue
1504
+ argv = list(j.get("runner") or []) + list(j["cmd"])
1505
+ env = {**(j.get("shell_env") or {}), **(j.get("env") or {}),
1506
+ "PP_QUEUE": q, "PP_QUEUE_JOB": str(jid), "PP_QUEUE_RCFILE": j["rcfile"]}
1507
+ if gpu is not None:
1508
+ env["CUDA_VISIBLE_DEVICES"] = str(gpu)
1509
+ retries = int(j.get("retries") or 0)
1510
+ attempt = f" attempt {j.get('attempt', 1)}/{retries + 1}" if retries else ""
1511
+ try:
1512
+ os.makedirs(os.path.dirname(j["log"]), exist_ok=True)
1513
+ with open(j["log"], "a") as f:
1514
+ f.write(f"[pp queue] {datetime.now():%Y-%m-%d %H:%M:%S} job {jid} ({q}){attempt} in {j['cwd']}: {shlex.join(argv)}\n")
1515
+ f.flush()
1516
+ p = proc.spawn(["/bin/sh", "-c", WRAP, "pp-queue-job", *argv], group="own", stdin=claim.fd,
1517
+ env="scrubbed", add_env=env, cwd=j["cwd"], stdout=f, stderr=proc.STDOUT)
1518
+ except OSError as e:
1519
+ with txn() as s:
1520
+ cur = s.job(jid, must_be=("running",))
1521
+ if cur:
1522
+ _finish(s, cur, "failed", None, f"launch failed: {e}", hook=_hook_due(cur, "failed"))
1523
+ claim.release() # after the record says it ended, so no reader sees it running with a free claim
1524
+ self.log(f"FAILED {jid} {j.get('tag')}: launch failed: {e}")
1525
+ if cur:
1526
+ self.fire(cur, "failed")
1527
+ return None
1528
+ claim.release() # pp's own copy of it; the wrapper's keeps the claim
1529
+ with txn() as s: # amend the record as it is now, not the copy launch read: a note added meanwhile stays
1530
+ cur = s.job(jid, must_be=("running",))
1531
+ if cur:
1532
+ cur.update(pid=p.pid, pgid=p.pid, pstart=p.handle.start if p.handle else None,
1533
+ boot=proc.boot_id())
1534
+ s.save(cur)
1535
+ if cur is None:
1536
+ # its record no longer says running, which only something outside pp can do to a job this worker is
1537
+ # starting; its process must not run on under a record that has ended
1538
+ p.end(signal.SIGTERM)
1539
+ self.log(f"job {jid} ended elsewhere while it started; stopped its process {p.pid}")
1540
+ self.children[jid] = p
1541
+ self.log(f"start {jid} {j.get('tag')} [{j.get('project')}] {_needs_str(j.get('needs'))}"
1542
+ + (f" on GPU{gpu}" if gpu is not None else "") + attempt)
1543
+ return cur
1544
+
1545
+ def _note(self, j: dict | None, why: str = ""):
1546
+ """Record what the head of the queue waits for, in this worker's own claim where `status` reads it,
1547
+ whenever it changes; log it only when its reason changes, not its detail after ';'."""
1548
+ now = None if j is None else {"id": j["id"], "tag": j.get("tag"), "why": why, "t": time.time()}
1549
+ if (self.waiting or {}).get("id") == (now or {}).get("id") \
1550
+ and (self.waiting or {}).get("why") == (now or {}).get("why"):
1551
+ return # the same job, waiting for the same thing
1552
+ self.waiting = now
1553
+ self._publish()
1554
+ key = None if j is None else (j["id"], why.split(";")[0])
1555
+ if key is not None and key != self.said:
1556
+ self.log(f"waiting: {j['id']} {j.get('tag')} for {why}")
1557
+ self.said = key
1558
+
1559
+
1560
+ def _worker_claim(q: str) -> str:
1561
+ return os.path.join(_qroot(q, create=False), "worker.lock")
1562
+
1563
+
1564
+ def _worker_log(q: str, create: bool = False) -> str:
1565
+ """Where queue q's worker writes what it does. Naming it creates nothing: a verb that only reports
1566
+ (`worker status`) leaves no directory behind for a queue name that was a typo. create: the worker
1567
+ about to open it for writing makes the queue's directories."""
1568
+ return os.path.join(_qroot(q, create=create), "worker.log")
1569
+
1570
+
1571
+ def worker(q: str) -> dict | None:
1572
+ """Queue q's live worker as it records itself: {pid, pstart, started, backfill, waiting}. None when the
1573
+ queue has no live worker.
1574
+
1575
+ Only a worker ever holds the claim on worker.lock, and it holds it for as long as it runs: that is what
1576
+ being up means. A worker that was killed leaves it free at once, and one that is alive holds it however
1577
+ long a tick takes. The record is what it is doing, written under the claim. A record that names no live
1578
+ process is a worker in its first moment, which has not recorded itself yet (`_said`). Asking never waits
1579
+ for it.
1580
+
1581
+ `worker_now` is the one place that asks this, and every verb asks `worker_now` or `worker_up`."""
1582
+ return lock.Claim.holder(_worker_claim(q))
1583
+
1584
+
1585
+ def _start_worker(q: str) -> proc.Child:
1586
+ """A detached worker for q, with no session identity in its environment (the caller may be an agent), once
1587
+ it is ready: it says so when it holds its claim, has recorded itself and handles a stop."""
1588
+ with open(_worker_log(q, create=True), "a") as log:
1589
+ return proc.spawn(paths.self_cmd() + [_PROG, "work", "-q", q], group="own", stdin="none", env="scrubbed",
1590
+ cwd="/", stdout=log, stderr=proc.STDOUT, ready=True)
1591
+
1592
+
1593
+ # ---- whether a queue's worker is up: one construct -------------------------------------------------
1594
+ #
1595
+ # Only a worker ever holds a queue's worker claim, and `work()` is the one place that takes it. So two things
1596
+ # can be true of that claim, and one of them at a time: a worker holds it, or nobody does.
1597
+ #
1598
+ # a worker holds the claim it is up, and its record says what it is doing
1599
+ # nobody holds the claim the queue has no worker, and a verb that needs one starts it here
1600
+ #
1601
+ # A worker in its first moment is neither: it has the claim and the record in it names no process, since a take
1602
+ # writes that record in the call after the lock. Such a look is no answer, so the construct looks again, a
1603
+ # bounded number of times, and says a worker is starting where the record still names nobody.
1604
+ #
1605
+ # A verb asks `worker_now`, which reports, or `worker_up`, which makes sure a worker is up, and prints from the
1606
+ # one answer it gets back. So no verb acts on a look it took outside the scope that acted.
1607
+
1608
+ UP = "up" # a worker holds the claim and has recorded itself
1609
+ STARTED = "started" # the claim was free, and the worker this start brought up holds it
1610
+ DOWN = "down" # nobody holds the claim
1611
+ STARTING = "starting" # a worker holds the claim and has not recorded itself yet
1612
+
1613
+ WORKER_STARTS = 3 # workers `worker_up` starts at most, each one asked about after it
1614
+ STARTING_LOOKS = 25 # looks it takes at a claim whose holder has not recorded itself ...
1615
+ STARTING_PAUSE = 0.02 # ... and how long apart, which is many times the one write that ends that moment
1616
+
1617
+
1618
+ @dataclass(frozen=True)
1619
+ class Holder:
1620
+ """What one look at a queue's worker claim came to, and the one thing every verb prints from.
1621
+
1622
+ how up, started, down or starting, as above.
1623
+ rec the record of the worker that holds the claim. None where nobody holds it.
1624
+ why why a round of starts brought no worker up, where this answer came from such a round. None
1625
+ otherwise. A start that times out is the one reason the worker's own log cannot carry, since a
1626
+ worker that hung before it said it was ready has logged nothing, so a caller that asked quietly
1627
+ says this itself.
1628
+ """
1629
+
1630
+ how: str
1631
+ rec: dict | None = None
1632
+ why: str | None = None
1633
+
1634
+ @property
1635
+ def up(self) -> bool:
1636
+ """Whether a worker is up: the one answer, so nothing has to read a record to find out."""
1637
+ return self.how in (UP, STARTED)
1638
+
1639
+ @property
1640
+ def pid(self) -> int | None:
1641
+ """The pid of the queue's worker. None where nobody holds the claim, and while a worker that holds it
1642
+ has not recorded itself yet."""
1643
+ return (self.rec or {}).get("pid") if self.up else None
1644
+
1645
+ @property
1646
+ def handle(self) -> proc.Handle | None:
1647
+ """The worker as the handle a signal to it goes through: a wake, or the SIGTERM of `worker stop`. None
1648
+ where this look found no worker. A worker that is up has recorded itself, so both halves are there."""
1649
+ return proc.Handle.recorded(self.pid, (self.rec or {}).get("pstart")) if self.up else None
1650
+
1651
+
1652
+ def _starting(q: str) -> str:
1653
+ """What a person is told while a worker holds q's claim and has not recorded itself yet."""
1654
+ return f"the {q} worker is starting and has not recorded itself yet"
1655
+
1656
+
1657
+ def _said(rec: dict | None) -> bool:
1658
+ """Whether the record in a worker claim is the record of the worker that holds it now.
1659
+
1660
+ A take locks the claim file, empties it and writes the record in that order (`lock.Claim.take`), so for
1661
+ that moment a reader finds the file empty, or holding the record a worker that was killed left. A release
1662
+ empties the file, and a worker's record names itself, so those bytes name a process that is gone. Either
1663
+ way a record that names no process of its own, or one that names a process that is not running, is a
1664
+ worker in its first moment."""
1665
+ return proc.Handle.running((rec or {}).get("pid"), (rec or {}).get("pstart")) is not None
1666
+
1667
+
1668
+ def worker_now(q: str) -> Holder:
1669
+ """What q's worker is: the one look at the claim's holder. It writes nothing and starts nothing.
1670
+
1671
+ A worker that holds the claim and has not recorded itself yet is no state to report, so this looks again,
1672
+ a bounded number of times. A holder that never records itself comes back as a worker starting, for the
1673
+ verb to say so rather than name it as up."""
1674
+ rec: dict | None = None
1675
+ for _ in range(STARTING_LOOKS):
1676
+ rec = worker(q)
1677
+ if rec is None:
1678
+ return Holder(DOWN)
1679
+ if _said(rec):
1680
+ return Holder(UP, rec=rec)
1681
+ time.sleep(STARTING_PAUSE) # its first moment: look again rather than decide from it
1682
+ return Holder(STARTING, rec=rec)
1683
+
1684
+
1685
+ # When a round of starts of this process last brought no worker up, per queue. A loop that asks on every pass
1686
+ # waits START_EVERY out before it starts another round, so a worker that cannot come up is not started over
1687
+ # and over. The entry goes as soon as a worker is found up, so a queue that recovers is not held off.
1688
+ _none_came_up: dict[str, float] = {}
1689
+
1690
+
1691
+ def _no_worker_came_up(q: str, quiet: bool, said: bool, w: Holder, why: str | None = None) -> Holder:
1692
+ """This process's round of starts brought no worker up: hold off from starting another for START_EVERY,
1693
+ and say so unless the caller asked quietly. -> that answer, carrying why on it for a quiet caller to say.
1694
+
1695
+ The clock is read here, after the starts, and not before them: a round can take longer than START_EVERY
1696
+ (a start waits for the worker to say it is ready), and a reading from before it would already be past."""
1697
+ _none_came_up[q] = time.time()
1698
+ if not quiet and not said:
1699
+ ui.warn(f"{_PROG}: the {q} worker did not come up; its log: {_worker_log(q)}")
1700
+ return Holder(w.how, rec=w.rec, why=why)
1701
+
1702
+
1703
+ def worker_up(q: str, quiet: bool = False, wake: bool = False) -> Holder:
1704
+ """Make sure q has a worker, and say what it is: what `add`, `kill`, `release`, `retry`, a wait loop and
1705
+ `worker start` all ask, and the one answer they print from.
1706
+
1707
+ A worker that is up comes back as it is, woken where wake asks, so a job this verb made ready launches now
1708
+ and not at the worker's next tick. Where nobody holds the claim this starts a worker and waits for it: the
1709
+ new worker says it is ready once it holds its claim, has recorded itself and has its stop handler in
1710
+ place, so a `worker stop` or a status right after this sees it. A start whose worker found the claim taken
1711
+ meanwhile brings up no worker, and then this asks again rather than guessing, WORKER_STARTS starts at
1712
+ most. Every answer is a decision taken after the last start this made, so no verb reports a state from
1713
+ before the act it reports on.
1714
+
1715
+ A worker in its first moment holds the claim, so no start can take it: this says a worker is starting and
1716
+ leaves it at that. Where a round of starts brought no worker up, the answer is a queue with no worker, and
1717
+ this process starts another round only once START_EVERY has passed, so a loop that asks on every pass does
1718
+ not start workers it cannot bring up ten times a second. A caller that is not quiet is told when its own
1719
+ round brought no worker up. An ask that lands inside the hold-off of an earlier round starts nothing and
1720
+ says nothing. Every answer from a round that brought none up carries the reason in `why`, so a caller
1721
+ that asked quietly, `worker start` and a watch loop among them, says it itself."""
1722
+ starts: list[int | None] = [] # the workers this started, which the look after each asks about
1723
+ while True:
1724
+ w = worker_now(q)
1725
+ if w.up:
1726
+ _none_came_up.pop(q, None) # it is up: nothing to hold the next start off for
1727
+ if w.pid is not None and w.pid in starts:
1728
+ if not quiet:
1729
+ print(f"pp {_PROG}: started the {q} worker (pid {w.pid}; log {_worker_log(q)})",
1730
+ file=sys.stderr)
1731
+ return Holder(STARTED, rec=w.rec)
1732
+ if wake:
1733
+ Worker.wake(w.handle)
1734
+ return w
1735
+ if w.how == STARTING:
1736
+ if not quiet: # the same thing every verb says about this state, from the answer it got
1737
+ print(f"pp {_PROG}: {_starting(q)}", file=sys.stderr)
1738
+ return w
1739
+ if not starts and time.time() < _none_came_up.get(q, 0.0) + START_EVERY:
1740
+ return w # a round of this process's brought none up that recently: not another yet
1741
+ if len(starts) == WORKER_STARTS:
1742
+ return _no_worker_came_up(q, quiet, False, w) # the claim's next holder shut this many out
1743
+ try:
1744
+ starts.append(_start_worker(q).pid) # starting a process is not store work: no lock is held
1745
+ except TimeoutError as e:
1746
+ if not quiet:
1747
+ ui.warn(f"{_PROG}: {e}; its log: {_worker_log(q)}")
1748
+ return _no_worker_came_up(q, quiet, True, w, why=str(e))
1749
+
1750
+
1751
+ def work(q: str, backfill: bool | None = None, poll: float | None = None) -> int:
1752
+ writing_queue(q) # a queue this pp cannot read is refused here too; the worker's own queue says its format
1753
+ from_cfg = backfill is None # `work --backfill` fixes it; otherwise it follows the config, like the rest
1754
+ if from_cfg:
1755
+ try:
1756
+ backfill = bool(_qcfg(q).get("backfill", False))
1757
+ except config.ConfigError:
1758
+ backfill = False
1759
+
1760
+ def log(msg):
1761
+ print(f"[{q} {datetime.now():%m-%d %H:%M:%S}] {msg}", flush=True)
1762
+
1763
+ w = Worker(q, bool(backfill), log)
1764
+ # the wake is held for the whole of this worker's life, before it claims the queue: a wake can only ever
1765
+ # reach a worker that takes it from its own wakeup pipe
1766
+ with waking() as waits:
1767
+ claim = lock.Claim.take(_worker_claim(q), w.record())
1768
+ if claim is None: # one worker per queue, and holding the claim is being that worker
1769
+ held = worker_now(q)
1770
+ if held.up:
1771
+ print(f"pp {_PROG}: the {q} worker is already running (pid {held.pid})")
1772
+ elif held.how == STARTING:
1773
+ print(f"pp {_PROG}: {_starting(q)}, so this worker is not the one")
1774
+ else:
1775
+ print(f"pp {_PROG}: another worker held the {q} worker's claim and has let it go")
1776
+ return 0
1777
+ w.claim = claim
1778
+ stop: list = []
1779
+ signal.signal(signal.SIGTERM, lambda *_: stop.append(1)) # a flag, never an exception mid-move
1780
+ proc.say_ready() # it holds its claim, it is recorded, a stop is handled and a wake is taken
1781
+ w.stopping = lambda: bool(stop)
1782
+ adopted = _jobs("running", q)
1783
+ log(f"worker up, pid {os.getpid()}" + (", backfill" if backfill else "")
1784
+ + (f", adopting {len(adopted)} running job(s)" if adopted else ""))
1785
+ idle_since = None
1786
+ bad_cfg = None
1787
+ woken = False
1788
+ try:
1789
+ while not stop:
1790
+ period, idle_exit, bad = poll or 2.0, 600.0, None
1791
+ try:
1792
+ cfg = _qcfg(q)
1793
+ period = float(poll or cfg.get("poll", 2))
1794
+ idle_exit = float(cfg.get("idle_exit", 600))
1795
+ if from_cfg and bool(cfg.get("backfill", False)) != w.backfill:
1796
+ w.set_backfill(bool(cfg.get("backfill", False)))
1797
+ log(f"backfill {'on' if w.backfill else 'off'} (config)")
1798
+ except config.ConfigError as e:
1799
+ bad = str(e)
1800
+ try:
1801
+ w.tick() # reaping and timeouts go on under a broken config; only launching needs it
1802
+ except config.ConfigError as e:
1803
+ bad = str(e)
1804
+ except Exception:
1805
+ log("tick failed:\n" + traceback.format_exc())
1806
+ if bad != bad_cfg:
1807
+ log(f"bad config, nothing launches until it is fixed: {bad}" if bad else "config fixed")
1808
+ bad_cfg = bad
1809
+ with listing() as s:
1810
+ if s.jobs(("queued", "running"), q):
1811
+ idle_since, spent = None, False
1812
+ else:
1813
+ idle_since = idle_since or time.time()
1814
+ spent = time.time() - idle_since >= idle_exit
1815
+ if spent:
1816
+ # let the claim go inside the scope that read the store, which is the one order
1817
+ # that leaves no job behind: a job stored after this finds the claim free and
1818
+ # starts the next worker, and one stored before it was read here, so this worker
1819
+ # stays
1820
+ claim.release()
1821
+ if spent:
1822
+ log(f"idle for {idle_exit:g}s, exiting")
1823
+ break
1824
+ # one wait, ended by the period or by a signal: a stop in flight is carried out in the next
1825
+ # tick, and a wake or a SIGTERM is acted on as it arrives. The short period goes with the
1826
+ # wake: a verb waiting for a stop wakes this worker on every pass of its own wait, so the
1827
+ # tick after that wake is this worker's next one. A stop nobody waits for, a timeout's, is
1828
+ # carried out at the queue's poll, and the store lock is not taken ten times a second for a
1829
+ # job the kernel is slow to end
1830
+ woken = waits.wait(min(period, STOP_PERIOD) if w.serving and woken else period)
1831
+ except KeyboardInterrupt:
1832
+ stop.append(1)
1833
+ finally:
1834
+ if stop:
1835
+ log("stopped; running jobs keep running and the next worker adopts them")
1836
+ claim.release()
1837
+ return 0
1838
+
1839
+
1840
+ # ---- watchers: the pull side ----------------------------------------------------------------------
1841
+ #
1842
+ # `wait` and `watch` hold a lease (watchers/<pid>-<pstart>.claim) while they run and stamp each outcome they print on the job
1843
+ # (`reported`). An agent that runs one in the background hears the outcome from it.
1844
+ #
1845
+ # The stamp: job["reported"] = [{t, by: "wait pid N", session}], written only by _stamp_reported (wait/watch; never
1846
+ # `log -f` or a hook). session is the caller's agent session, None for a person. Readers:
1847
+ # wait --mine collects the session's ended jobs that carry no stamp of that session, so a job that fails
1848
+ # between `add` and `wait --mine` is still reported and the wait does not exit 0. Cost: the first
1849
+ # --mine of a session that never waited prints every outcome it never heard, old ones included; a
1850
+ # per-session cursor would avoid that with more state.
1851
+ # show "reported to" rows
1852
+
1853
+ def _watcher_claim(pid: int, pstart: int) -> str:
1854
+ """The file one `wait` or `watch` holds its lease on. The name is that process's own, so nobody else ever
1855
+ takes it and the file goes with the lease."""
1856
+ return os.path.join(_top("watchers"), f"{pid}-{pstart}.claim")
1857
+
1858
+
1859
+ @contextmanager
1860
+ def watching(ids: list[int] | None, scope: list[str] | None, verb: str):
1861
+ """Hold a lease on these jobs (ids None: every job of scope; scope None: every queue) while the block runs."""
1862
+ me, who = proc.read(os.getpid()), session.caller()
1863
+ # the lease is a claim this process holds while it waits, under a name that is this process's own: the
1864
+ # kernel lets it go when the process ends, however it ends, so no reader has to judge whether it is alive.
1865
+ # It is locked before that name is visible (lock.Claim.make), so the sweep below can never take it from a
1866
+ # wait that is taking it: a lease that is there is held, and one that is not held has lost its holder.
1867
+ claim = lock.Claim.make(_watcher_claim(os.getpid(), me.start if me else 0),
1868
+ {"pid": os.getpid(), "ids": ids, "scope": scope, "verb": verb,
1869
+ "session": who["session"] if who else None, "t": time.time()})
1870
+ try:
1871
+ yield
1872
+ finally:
1873
+ claim.release()
1874
+ lock.Claim.forget(claim.path) # nobody else ever takes this name, so this frees nothing of theirs
1875
+
1876
+
1877
+ def watchers(job: dict | None = None) -> list[dict]:
1878
+ """The live `wait` / `watch` processes holding a lease on this job, and none when job is None, which is
1879
+ the sweep on its own.
1880
+
1881
+ A lease nobody holds is removed wherever this is asked. A wait ended by a signal runs no finally of its
1882
+ own: the kernel let its claim go as the process died, and the file it was holding goes here. `show` asks
1883
+ about one job, and a worker's tick asks about none, so the leases of an agent's interrupted waits are
1884
+ swept where the queue is being read anyway.
1885
+
1886
+ A lease is locked before its name is visible (`watching`), so a lease that is there and unheld is one
1887
+ whose holder has gone, and this takes nothing from a wait that is taking its own."""
1888
+ d = _top("watchers")
1889
+ out = []
1890
+ for fn in sorted(os.listdir(d)):
1891
+ if not fn.endswith(".claim"):
1892
+ continue
1893
+ path = os.path.join(d, fn)
1894
+ rec = lock.Claim.holder(path)
1895
+ if rec is None:
1896
+ lock.Claim.forget(path) # the process whose own name this is has gone, so the file goes too
1897
+ continue
1898
+ if job is None or not rec.get("pid"):
1899
+ continue # a sweep, which asks about none; or a record that names nobody
1900
+ if (rec.get("ids") is None or job["id"] in rec["ids"]) and (rec.get("scope") is None or job["queue"] in rec["scope"]):
1901
+ out.append(rec)
1902
+ return out
1903
+
1904
+
1905
+ def _stamp_reported(jid: int, verb: str):
1906
+ who = session.caller()
1907
+ with txn() as s:
1908
+ j = s.job(jid, must_be=ENDED)
1909
+ if j:
1910
+ j.setdefault("reported", []).append({"t": time.time(), "by": f"{verb} pid {os.getpid()}",
1911
+ "session": who["session"] if who else None})
1912
+ s.save(j)
1913
+
1914
+
1915
+ # ---- helpers for verbs ----------------------------------------------------------------------------
1916
+
1917
+ def _resolve(specs: list[str], scope: list[str] | None = None) -> list[dict]:
1918
+ """Jobs named by ids, ranges (12-15) or exact tags, in the order given, without duplicates. Ids are global.
1919
+ Each carries _bulk: it came from a range or a tag naming several jobs, so a verb skips it when it is in a state
1920
+ the verb does not apply to, where a job named on its own is an error."""
1921
+ by_id = {j["id"]: j for j in _jobs(queue=scope)}
1922
+ out, seen = [], set()
1923
+ for s in specs:
1924
+ m = re.fullmatch(r"(\d+)-(\d+)", s)
1925
+ if m:
1926
+ hits = [by_id[i] for i in range(int(m[1]), int(m[2]) + 1) if i in by_id]
1927
+ elif s.isdigit():
1928
+ hits = [by_id[int(s)]] if int(s) in by_id else []
1929
+ else:
1930
+ hits = sorted((j for j in by_id.values() if j.get("tag") == s), key=lambda j: j["id"])
1931
+ qs = sorted({j["queue"] for j in hits})
1932
+ if len(qs) > 1:
1933
+ ui.die(f"{_PROG}: tag '{s}' names jobs in queues {', '.join(qs)}: "
1934
+ + ", ".join(f"{j['id']} ({j['queue']})" for j in hits) + "; use ids, or -q", 2)
1935
+ if not hits:
1936
+ ui.die(f"{_PROG}: no job matches '{s}'", 2)
1937
+ for j in hits:
1938
+ if j["id"] not in seen:
1939
+ seen.add(j["id"])
1940
+ out.append({**j, "_bulk": bool(m) or len(hits) > 1})
1941
+ return out
1942
+
1943
+
1944
+ def _one(spec: str, verb: str, scope: list[str] | None = None) -> dict:
1945
+ """The job show, note and log act on. A tag naming several jobs (a retry keeps the tag): the newest, said so;
1946
+ note refuses, since a note on the wrong job misleads."""
1947
+ hits = _resolve([spec], scope)
1948
+ if len(hits) > 1:
1949
+ ids = ", ".join(str(j["id"]) for j in hits)
1950
+ if verb == "note":
1951
+ ui.die(f"{_PROG} note: '{spec}' names jobs {ids}; note one by its id", 2)
1952
+ ui.warn(f"{_PROG} {verb}: '{spec}' names jobs {ids}; showing the newest, {hits[-1]['id']}")
1953
+ return hits[-1]
1954
+
1955
+
1956
+ def _left(skipped: list[dict]) -> None:
1957
+ """Jobs a range or tag swept over that the verb does not apply to: listed once, not errors."""
1958
+ if skipped:
1959
+ print(ui.c("left as they are: " + ", ".join(f"{j['id']} {j['state']}" for j in skipped), "dim"))
1960
+
1961
+
1962
+ def _gate(a, jobs: list[dict], verb: str, ok: tuple) -> int | None:
1963
+ """kill and drop: a job named by its id is acted on at once; a range or a tag naming several jobs is shown first
1964
+ and asked about (-y: do not ask). -n only shows. None to go ahead, else the exit code."""
1965
+ if not a.dry_run and not any(j["_bulk"] for j in jobs):
1966
+ return None
1967
+ hits = [j for j in jobs if j["state"] in ok]
1968
+ print(f"{verb} {len(hits)} job(s): "
1969
+ + (", ".join(f"{j['id']} {_text(j.get('tag'))}" for j in hits) or "none"))
1970
+ _left([j for j in jobs if j["state"] not in ok])
1971
+ if a.dry_run or not hits:
1972
+ return 0
1973
+ return None if ui.confirm(f"{verb} them?", a.yes) else 2
1974
+
1975
+
1976
+ def _ranges(ids: list[int]) -> str:
1977
+ """[1, 2, 3, 7] -> '1-3, 7' (sorted ids)."""
1978
+ out: list[list[int]] = []
1979
+ for i in ids:
1980
+ if out and i == out[-1][1] + 1:
1981
+ out[-1][1] = i
1982
+ else:
1983
+ out.append([i, i])
1984
+ return ", ".join(str(a) if a == b else f"{a}-{b}" for a, b in out)
1985
+
1986
+
1987
+ def _needs_str(needs: dict | None) -> str:
1988
+ out = []
1989
+ for k, v in (needs or {}).items():
1990
+ if k == "slots" and v == 1:
1991
+ continue
1992
+ out.append({"gpu_gb": f"gpu {v:g}G", "mem_gb": f"mem {v:g}G"}.get(k, f"{k} {v:g}"))
1993
+ return " ".join(out)
1994
+
1995
+
1996
+ def project_name(cwd: str) -> str:
1997
+ root, data = config.find_project(cwd)
1998
+ if root:
1999
+ return config.project_name(root, data)
2000
+ g = config.git_root(cwd)
2001
+ return os.path.basename(os.path.normpath(g or cwd))
2002
+
2003
+
2004
+ def _kv(items: list[str] | None, flag: str, conv) -> dict:
2005
+ out = {}
2006
+ for it in items or []:
2007
+ k, sep, v = it.partition("=")
2008
+ if not sep or not k:
2009
+ ui.die(f"{_PROG}: {flag} takes NAME=VALUE, got '{it}'", 2)
2010
+ try:
2011
+ out[k] = conv(v)
2012
+ except ValueError:
2013
+ ui.die(f"{_PROG}: {flag} {k}: '{v}' is not a number", 2)
2014
+ return out
2015
+
2016
+
2017
+ def _from_lines(src: str) -> list[list[str]]:
2018
+ try:
2019
+ text = sys.stdin.read() if src == "-" else open(paths.expand(src)).read()
2020
+ except OSError as e:
2021
+ ui.die(f"{_PROG} add --from: {e.strerror}: {src}", 2)
2022
+ out = []
2023
+ for n, line in enumerate(text.splitlines(), 1):
2024
+ if line.strip() and not line.lstrip().startswith("#"):
2025
+ try:
2026
+ out.append(shlex.split(line))
2027
+ except ValueError as e:
2028
+ ui.die(f"{_PROG} add --from {src}: line {n}: {e}: {line.strip()[:80]} (nothing was queued)", 2)
2029
+ return out
2030
+
2031
+
2032
+ def _last_line(path: str | None, width: int) -> str:
2033
+ """The job's latest output line, never pp's own banner: a job that has printed nothing yet would show that
2034
+ banner as its line."""
2035
+ if not path:
2036
+ return ""
2037
+ try:
2038
+ lines = _tail(path, 5)
2039
+ except OSError:
2040
+ return ""
2041
+ for line in reversed(lines):
2042
+ line = _text(ANSI.sub("", line).strip())
2043
+ if line and not line.startswith("[pp queue] "):
2044
+ return line[:width]
2045
+ return ""
2046
+
2047
+
2048
+ def _in_delay(not_before: float | None, now: float | None = None) -> str:
2049
+ """How long until a retry's delay is up, as a person reads it. Never negative: a worker notices an attempt
2050
+ ended a poll period after it did, so the delay of a job it is about to re-queue may be up already. The one
2051
+ place that remaining delay is put into words, for the three that print it."""
2052
+ return ui.human_duration(max(float(not_before or 0) - (now or time.time()), 0))
2053
+
2054
+
2055
+ def _section(rows: list[list], tag: int, width: int, indent: str = " ") -> list[str]:
2056
+ """One section of `pp queue status` as its lines: one table, so every row of it lines up, with the column
2057
+ at `tag` cut until the widest row fits a terminal `width` wide.
2058
+
2059
+ A job's tag is its command cut to 40 characters unless it was named, so that is the column with something
2060
+ to give; the rest of a row is pp's own, and a row whose other columns alone are wider than the terminal
2061
+ keeps eight characters of tag and runs past the edge. Empty cells at the end of a row are left out, so no
2062
+ row ends in the padding of a column nothing filled."""
2063
+ rows = [_without_empty_tail(r) for r in rows]
2064
+ keep = max((ui.width(str(r[tag])) for r in rows), default=0)
2065
+ while True:
2066
+ out = ui.table([[ui.cut(str(v), keep) if i == tag else v for i, v in enumerate(r)] for r in rows],
2067
+ indent=indent).splitlines()
2068
+ if keep <= 8 or max((ui.width(ln) for ln in out), default=0) <= width:
2069
+ return out
2070
+ keep -= 1
2071
+
2072
+
2073
+ def _without_empty_tail(row: list) -> list:
2074
+ out = list(row)
2075
+ while len(out) > 1 and not str("" if out[-1] is None else out[-1]):
2076
+ out.pop()
2077
+ return out
2078
+
2079
+
2080
+ def _latest_note(j: dict, width: int = 60) -> str:
2081
+ notes = j.get("notes") or []
2082
+ return ("note: " + _text(notes[-1]["text"].replace("\n", " "), width)) if notes else ""
2083
+
2084
+
2085
+ def public(j: dict) -> dict:
2086
+ """A job as JSON consumers (`show/status --json`) see it: every stored field.
2087
+
2088
+ Fields others rely on: id (int), state, queue, tag, project, cwd, cmd (argv), runner (argv, maybe []), needs,
2089
+ prio, owner {session, cwd} or None, timeout, retries, attempt, rc, reason, seconds, log,
2090
+ notes [{t, by, text}], reported [{t, by, session}], stop {by, t, grace, timeout, sent, kill9, denied, was}
2091
+ on a job asked to stop, and from `show` also watchers [{pid, verb, session}].
2092
+
2093
+ A stop's `timeout` is set on the one a worker wrote because the job's own time ran out, and on no other,
2094
+ so what a stop is never rests on the name of whoever asked. `sent` and `kill9` are when each signal went
2095
+ out, and `denied` is when pp was refused the job's process group. A stop with `denied` set is not in
2096
+ flight: nothing more goes out for it, and the job runs on until it ends on its own. `was` is who asked
2097
+ for the stop this one landed on, where a kill landed on a timeout's."""
2098
+ return {k: v for k, v in j.items() if not k.startswith("_")}
2099
+
2100
+
2101
+ def _ended_line(j: dict | None, jid: int) -> str:
2102
+ if j is None:
2103
+ return f"dropped {jid}"
2104
+ word = "done" if j["state"] == "done" else ui.c("FAILED", "red")
2105
+ why = f" ({j['reason']})" if j.get("reason") else ""
2106
+ return f"{word} {jid} rc={_rc(j)}{why} {ui.human_duration(j.get('seconds'))} {_text(j.get('tag'))}"
2107
+
2108
+
2109
+ def _rc(j: dict):
2110
+ """A job's exit code as shown: '-' when there is none (killed, lost, never started)."""
2111
+ return "-" if j.get("rc") is None else j["rc"]
2112
+
2113
+
2114
+ def _outcome_lines(j: dict | None, jid: int, tail: int) -> list[str]:
2115
+ """A job's final outcome as wait/watch print it: one line, and for a failure its log tail and what to do next."""
2116
+ out = [_ended_line(j, jid)]
2117
+ if j and j.get("not_retried"):
2118
+ out.append(f" not retried: {j['not_retried']}")
2119
+ if j and j["state"] == "failed" and tail > 0:
2120
+ out += [" | " + ln for ln in failure_tail(j, tail)]
2121
+ out.append(f" next: pp {_PROG} log {jid} | pp {_PROG} retry {jid} | pp {_PROG} note {jid} \"why\"")
2122
+ return out
2123
+
2124
+
2125
+ def _attempt_str(j: dict) -> str:
2126
+ retries = int(j.get("retries") or 0)
2127
+ return f"attempt {j.get('attempt', 1)}/{retries + 1}" if retries else ""
2128
+
2129
+
2130
+ # ---- verbs ----------------------------------------------------------------------------------------
2131
+
2132
+ def cmd_add(a, cmd: list[str], q: str, prepare_job) -> int:
2133
+ cmds = _from_lines(a.from_) if a.from_ else [cmd]
2134
+ if not cmds or not all(cmds):
2135
+ ui.die(f"{_PROG} add: no command. Put it after -- (pp {_PROG} add -- python x.py)", 2)
2136
+ cwd = paths.expand(a.cwd) if a.cwd else os.getcwd()
2137
+ if not os.path.isdir(cwd):
2138
+ ui.die(f"{_PROG} add: no directory {cwd}", 2)
2139
+ env = _kv(a.env, "--env", str)
2140
+ needs: dict = {"slots": 1}
2141
+ for k, v in (("gpu_gb", a.gpu_gb), ("cpu", a.cpu), ("mem_gb", a.mem_gb)):
2142
+ if v:
2143
+ needs[k] = _num(v)
2144
+ needs.update(_kv(a.need, "--need", _num))
2145
+ for k, v in needs.items():
2146
+ if not 0 < v < float("inf"): # also nan: it compares false with everything, so it would never wait
2147
+ ui.die(f"{_PROG} add: {k}={v:g}: a need is a positive number", 2)
2148
+ why = Resources(q, needs, live=False).check(needs)
2149
+ if why:
2150
+ ui.die(f"{_PROG} add: {why}", 2)
2151
+ qc = _qcfg(q)
2152
+ if a.timeout is None:
2153
+ timeout = qc.get("timeout")
2154
+ else: # none (or 0): no timeout, whatever the queue's default
2155
+ timeout = None if a.timeout.strip().lower() == "none" else _dur_arg(a.timeout, "--timeout")
2156
+ timeout = timeout or None
2157
+ retries = a.retries if a.retries is not None else qc.get("retries", 0)
2158
+ if retries < 0:
2159
+ ui.die(f"{_PROG} add: --retries must be >= 0", 2)
2160
+ retry_delay = _dur_arg(a.retry_delay, "--retry-delay") if a.retry_delay is not None else qc.get("retry_delay", RETRY_DELAY)
2161
+ retry_if = a.retry_if if a.retry_if is not None else qc.get("retry_if")
2162
+ if retry_if:
2163
+ try:
2164
+ re.compile(retry_if)
2165
+ except re.error as e:
2166
+ ui.die(f"{_PROG} add: --retry-if {retry_if!r}: {e}", 2)
2167
+ if a.tag and re.fullmatch(r"\d+(-\d+)?", a.tag):
2168
+ ui.die(f"{_PROG} add: --tag {a.tag} reads as a job id or a range, so it could not select its jobs; "
2169
+ "use a word", 2)
2170
+ owner = session.caller()
2171
+ on_fail = _hook_spec(a.on_fail if a.on_fail is not None else qc.get("on_fail"))
2172
+ on_done = _hook_spec(a.on_done if a.on_done is not None else qc.get("on_done"))
2173
+ shell_env = {k: os.environ[k] for k in CAPTURED_ENV if k in os.environ}
2174
+ state = "held" if a.hold else "queued"
2175
+ project = project_name(cwd)
2176
+ ids = []
2177
+ for c in cmds:
2178
+ job = {"queue": q, "tag": a.tag or default_tag(c), "project": project, "cwd": cwd, "cmd": c, "runner": [],
2179
+ "env": dict(env), "shell_env": shell_env, "needs": needs, "prio": a.prio, "owner": owner,
2180
+ "timeout": timeout, "retries": retries, "retry_delay": retry_delay, "retry_if": retry_if or None,
2181
+ "attempt": 1, "on_fail": on_fail, "on_done": on_done}
2182
+ if prepare_job:
2183
+ prepare_job(job, a)
2184
+ with txn() as s:
2185
+ jid = s.add(job, state)["id"]
2186
+ ids.append(jid)
2187
+ print(jid)
2188
+ extra = " ".join(x for x in (f"timeout {ui.human_duration(timeout)}" if timeout else "",
2189
+ f"retries {retries}" if retries else "",
2190
+ f"on-fail {on_fail}" if on_fail else "") if x)
2191
+ via = f" (via {_text(os.path.basename(job['runner'][0]))})" if job["runner"] else ""
2192
+ print(f"pp {_PROG}: {state} {jid} ({q}) [{_text(project)}] p{a.prio} {_needs_str(needs)} {extra} "
2193
+ f"{_text(shlex.join(c), 100)}{via}", file=sys.stderr)
2194
+ if not a.hold and not a.no_start:
2195
+ worker_up(q, wake=True)
2196
+ if owner: # the pull side: an agent hears the outcome by waiting on it in the background
2197
+ some = " ".join(map(str, ids))
2198
+ print(f"pp {_PROG}: to hear how it ends, run `pp {_PROG} wait {some}` in the background (it exits with the "
2199
+ f"outcome and, on failure, the log tail), or `pp {_PROG} watch {some}` for a line per event", file=sys.stderr)
2200
+ return 0
2201
+
2202
+
2203
+ def _scope_list(scope: list[str] | None) -> list[str]:
2204
+ """The queues a verb works on: every one there is, or the ones it was given by name, each one opened."""
2205
+ if scope is None:
2206
+ return queues()
2207
+ for q in scope:
2208
+ open_queue(q)
2209
+ return scope
2210
+
2211
+
2212
+ def cmd_status(a, scope: list[str] | None) -> int:
2213
+ """The state of every queue in scope, or of the one named with -q.
2214
+
2215
+ `--json` is what an agent reads, and this is its shape, stated once:
2216
+
2217
+ queues [name], the queues this status covers
2218
+ workers {queue: pid or null}, the pid of each queue's worker; null where none is up and while
2219
+ one that holds the claim has not recorded itself yet
2220
+ starting [queue] whose worker holds the claim and has not recorded itself yet, which is the
2221
+ first moment of a worker: one is coming up, and it launches the queue's jobs
2222
+ waiting {queue: {id, tag, why, t} or null}, what the head of each queue waits for
2223
+ running, queued, held [job], each one as `public()` gives it
2224
+ recent [job], the jobs that ended most recently, at most -n of them
2225
+ pools {queue: [pool]}, each queue's own pools
2226
+ shared [pool], the pools every queue draws from; a pool is {name, max, used, probe, reading}
2227
+ cards [{index, name, total_gb, used_gb, free_gb, util}], the GPUs nvidia-smi reports
2228
+ config_errors [message]; while there is one, workers launch nothing
2229
+
2230
+ A field change goes in CHANGELOG.md (CONTRIBUTING.md, "Adding a command").
2231
+ """
2232
+ cards = _cards() # before the listing: nvidia-smi may take seconds, and a reader holds the store
2233
+ now = time.time()
2234
+ # one listing: the counts, the rows and what the shared pools hold are the same jobs, so no job is shown
2235
+ # twice or in two states. Every queue is listed, since a shared pool counts every queue's running jobs.
2236
+ all_jobs = _jobs()
2237
+ # what each queue's worker is, through the one construct, so the hints that nothing launches or records
2238
+ # this queue's jobs come back from the one answer it gave
2239
+ workers = {q: worker_now(q) for q in _scope_list(scope)}
2240
+ qs = [q for q in _scope_list(scope)
2241
+ if scope is not None or any(j["queue"] == q for j in all_jobs) or workers[q].how != DOWN]
2242
+ if a.here or a.project:
2243
+ want = a.project or project_name(os.getcwd())
2244
+ keep = lambda js: [j for j in js if j.get("project") == want] # noqa: E731
2245
+ else:
2246
+ keep = lambda js: js # noqa: E731
2247
+ running_all = [j for j in all_jobs if j["state"] == "running"]
2248
+ per: dict = {}
2249
+ bad: list[str] = [] # a broken config stops launches, not this view of what is running
2250
+
2251
+ def usage(q, sc):
2252
+ try:
2253
+ return Resources(q, live=False, empty_ok=False).usage(running_all, sc)
2254
+ except config.ConfigError as e:
2255
+ if str(e) not in bad:
2256
+ bad.append(str(e))
2257
+ return []
2258
+ shared = usage(qs[0] if qs else "default", None) # the same for every queue
2259
+ for q in qs:
2260
+ mine = [j for j in all_jobs if j["queue"] == q]
2261
+ data = {st: keep([j for j in mine if j["state"] == st]) for st in STATES}
2262
+ own = usage(q, q)
2263
+ w = workers[q]
2264
+ waiting = (w.rec or {}).get("waiting") if w.up else None # only a live worker waits for anything
2265
+ per[q] = {"data": data, "wpid": w.pid, "how": w.how, "pools": own, "waiting": waiting,
2266
+ "recent": sorted(data["done"] + data["failed"], key=lambda j: -(j.get("ended") or 0))}
2267
+ if a.json:
2268
+ flat = lambda st: [public(j) for q in qs for j in per[q]["data"][st]] # noqa: E731
2269
+ recent = sorted((j for q in qs for j in per[q]["recent"]), key=lambda j: -(j.get("ended") or 0))
2270
+ ui.print_json({"cards": cards, "queues": qs, "workers": {q: per[q]["wpid"] for q in qs},
2271
+ "starting": [q for q in qs if per[q]["how"] == STARTING],
2272
+ "pools": {q: per[q]["pools"] for q in qs}, "shared": shared,
2273
+ "waiting": {q: per[q]["waiting"] for q in qs},
2274
+ **{st: flat(st) for st in ("running", "queued", "held")},
2275
+ "recent": [public(j) for j in recent[:a.n]], "config_errors": bad})
2276
+ return 1 if bad else 0
2277
+ width = shutil.get_terminal_size((120, 20)).columns
2278
+ gpu = " ".join(f"GPU{c['index']} {c['util']}% {c['used_gb']:.1f}/{c['total_gb']:.0f}G" for c in cards) if cards else (
2279
+ "GPU n/a" + (f" ({CARDS_WHY})" if CARDS_WHY else ""))
2280
+ print(f"{gpu} {datetime.now():%Y-%m-%d %H:%M:%S}")
2281
+ pool_strs = [_pool_str(p, now) for p in shared if p["used"] or p["probe"] or p["name"] not in SHARED_DEFAULTS]
2282
+ if pool_strs:
2283
+ print("shared: " + " · ".join(pool_strs))
2284
+ for e in bad:
2285
+ print(ui.c(f"bad config: {e}. Workers launch nothing until it is fixed", "yellow"))
2286
+ if not qs:
2287
+ print(f"no queues yet: `pp {_PROG} add -- CMD` starts one")
2288
+ return 1 if bad else 0
2289
+ if scope and scope[0] not in queues() and not _configured(scope[0]):
2290
+ known = [q for q in queues() if q != scope[0]]
2291
+ print(f"no queue '{scope[0]}' yet" + (f" (there are: {', '.join(known)})" if known else ""))
2292
+ if (a.here or a.project) and not any(per[q]["data"][st] for q in qs for st in STATES):
2293
+ print(f"no jobs of project '{want}'")
2294
+ for q in qs:
2295
+ s = per[q]
2296
+ data, waiting, wpid = s["data"], s["waiting"], s["wpid"]
2297
+ up = (f"worker up (pid {wpid})" if wpid is not None else
2298
+ ui.c("worker starting" if s["how"] == STARTING else "worker down", "yellow"))
2299
+ usage = [_pool_str(p, now) for p in s["pools"]]
2300
+ head = ui.c(f"== {q}", "bold") + f" {up}"
2301
+ counts = " · ".join(usage + [f"queued {len(data['queued'])}", f"held {len(data['held'])}",
2302
+ f"done {len(data['done'])}", f"failed {len(data['failed'])}"])
2303
+ # the counts go on a line of their own where the two do not fit the terminal side by side
2304
+ fits = ui.width(head) + 3 + ui.width(counts) <= width
2305
+ print("\n" + head + (" " if fits else "\n ") + counts)
2306
+ if data["running"]:
2307
+ print(ui.c("running", "bold"))
2308
+ rows, lasts = [], []
2309
+ for j in data["running"]:
2310
+ extra = " ".join(x for x in (_attempt_str(j),
2311
+ f"STOPPING ({_text(j['stop']['by'])})" if j.get("stop") else "",
2312
+ _latest_note(j)) if x)
2313
+ row = [j["id"], _text(j.get("tag")), f"[{_text(j.get('project'))}]",
2314
+ ui.human_duration(now - (j.get("started") or now)),
2315
+ _needs_str(j.get("needs")) + (f" on GPU{j['gpu']}" if j.get("gpu") is not None else "")]
2316
+ rows.append(row + [extra])
2317
+ lasts.append(_last_line(j.get("log"), max(width - 8, 20)))
2318
+ for line, last in zip(_section(rows, 1, width), lasts):
2319
+ print(line)
2320
+ if last:
2321
+ print(ui.c(" " + last, "dim"))
2322
+ if s["how"] == DOWN:
2323
+ print(ui.c(f" nothing records how they end: `pp {_PROG} worker start -q {q}`", "yellow"))
2324
+ elif s["how"] == STARTING:
2325
+ print(ui.c(" a worker is starting; it records how they end", "yellow"))
2326
+ if data["queued"]:
2327
+ print(ui.c("queued (run order)", "bold"))
2328
+ rows = []
2329
+ for j in data["queued"][:max(a.n, 1) * 3]:
2330
+ why = waiting["why"] if waiting and waiting.get("id") == j["id"] else ""
2331
+ nb = j.get("not_before") or 0
2332
+ extra = " ".join(x for x in (_attempt_str(j), f"retry in {_in_delay(nb, now)}" if nb > now else "",
2333
+ ui.c("waiting: " + why, "yellow") if why else "", _latest_note(j)) if x)
2334
+ row = [j["id"], f"p{j.get('prio')}", _text(j.get("tag")), f"[{_text(j.get('project'))}]",
2335
+ _needs_str(j.get("needs"))]
2336
+ rows.append(row + [extra])
2337
+ print("\n".join(_section(rows, 2, width)))
2338
+ if len(data["queued"]) > len(rows):
2339
+ print(f" ... {len(data['queued']) - len(rows)} more")
2340
+ if s["how"] == DOWN:
2341
+ print(ui.c(f" nothing launches them: `pp {_PROG} worker start -q {q}`", "yellow"))
2342
+ elif s["how"] == STARTING:
2343
+ print(ui.c(" a worker is starting; it launches them", "yellow"))
2344
+ if data["held"]:
2345
+ print(ui.c("held", "bold"))
2346
+ rows = []
2347
+ for j in data["held"]:
2348
+ row = [j["id"], f"p{j.get('prio')}", _text(j.get("tag")), f"[{_text(j.get('project'))}]"]
2349
+ rows.append(row + [_latest_note(j)])
2350
+ print("\n".join(_section(rows, 2, width)))
2351
+ if s["recent"]:
2352
+ print(ui.c("recent", "bold"))
2353
+ rows = []
2354
+ for j in s["recent"][:a.n]:
2355
+ word = "done" if j["state"] == "done" else ui.c("FAILED", "red")
2356
+ rows.append([j["id"], word, f"rc={_rc(j)}", ui.human_duration(j.get("seconds")),
2357
+ _text(j.get("tag")), f"[{_text(j.get('project'))}]", ui.human_age(j.get("ended"), now)
2358
+ + (f" {j['reason']}" if j.get("reason") else "") + (f" {_latest_note(j)}" if j.get("notes") else "")])
2359
+ print("\n".join(_section(rows, 4, width)))
2360
+ return 1 if bad else 0
2361
+
2362
+
2363
+ def _configured(q: str) -> bool:
2364
+ try:
2365
+ return q in config.load("queue")
2366
+ except config.ConfigError:
2367
+ return False
2368
+
2369
+
2370
+ def _pool_str(p: dict, now: float) -> str:
2371
+ out = p["name"] + (f" {p['used']:g}/{p['max']:g}" if p["max"] is not None
2372
+ else (f" {p['used']:g} in use" if p["used"] else ""))
2373
+ if p["probe"]:
2374
+ r = p["reading"]
2375
+ out += (" (probe: not run yet)" if not r else f" (probe: {r['value']:g} free, {ui.human_age(r['t'], now)})"
2376
+ if r.get("value") is not None else f" (probe failed {ui.human_age(r['t'], now)}: {r.get('error')})")
2377
+ return out
2378
+
2379
+
2380
+ def cmd_show(a) -> int:
2381
+ j = _one(a.id, "show", a.scope)
2382
+ watched = [{k: w[k] for k in ("pid", "verb", "session")} for w in watchers(j)]
2383
+ if a.json:
2384
+ ui.print_json({**public(j), "watchers": watched})
2385
+ return 0
2386
+ state = j["state"] if j["state"] != "failed" else ui.c("FAILED", "red")
2387
+ head = f"job {j['id']} ({j['queue']}) {state}"
2388
+ if j["state"] in ENDED:
2389
+ head += f" rc={_rc(j)}" + (f" ({j['reason']})" if j.get("reason") else "")
2390
+ print(ui.c(head, "bold"))
2391
+ owner = j.get("owner")
2392
+ rows = [["tag", _text(j.get("tag"))], ["project", _text(j.get("project"))], ["cwd", _text(j.get("cwd"))],
2393
+ ["command", _text(shlex.join(list(j.get("runner") or []) + list(j.get("cmd") or [])))],
2394
+ ["needs", _needs_str(j.get("needs")) or "slots 1"], ["prio", j.get("prio")],
2395
+ ["attempt", f"{j.get('attempt', 1)}/{int(j.get('retries') or 0) + 1}"
2396
+ + (f" retry delay {ui.human_duration(j.get('retry_delay'))}" if j.get("retries") else "")
2397
+ + (f" retry if /{j['retry_if']}/" if j.get("retry_if") else "")],
2398
+ ["timeout", ui.human_duration(j["timeout"]) if j.get("timeout") else "-"],
2399
+ ["on fail", _text(j.get("on_fail")) or "-"], ["on done", _text(j.get("on_done")) or "-"],
2400
+ ["owner", f"session {_text(owner['session'])} in {_text(owner.get('cwd')) or '-'}" if owner else "-"],
2401
+ ["added", datetime.fromtimestamp(j["added"]).strftime("%Y-%m-%d %H:%M:%S") if j.get("added") else "-"]]
2402
+ if j.get("started"):
2403
+ rows.append(["started", datetime.fromtimestamp(j["started"]).strftime("%Y-%m-%d %H:%M:%S")])
2404
+ if j.get("ended"):
2405
+ rows.append(["ended", datetime.fromtimestamp(j["ended"]).strftime("%Y-%m-%d %H:%M:%S")
2406
+ + f" ({ui.human_duration(j.get('seconds'))})"])
2407
+ if j.get("stop"):
2408
+ st = j["stop"]
2409
+ # one stop per run carries one time, which is when the first ask landed. A kill that landed on a
2410
+ # timeout's stop is a kill, and the row says whose stop that time belongs to rather than reading as
2411
+ # the time the kill asked at
2412
+ when = f"{datetime.fromtimestamp(st['t']):%H:%M:%S}"
2413
+ rows.append(["stop", (f"{_text(st.get('by'))}, on the {_text(st['was'])}'s stop from {when}"
2414
+ if st.get("was") else f"{_text(st.get('by'))} asked at {when}")
2415
+ + (", SIGTERM sent" if st.get("sent") else ", nothing signalled")
2416
+ + (", SIGKILL sent" if st.get("kill9") else "")
2417
+ # a denied stop is not in flight: say so, or it reads as one still being carried out
2418
+ + (f"; pp is not allowed to signal its group {j.get('pgid')}, so nothing more goes out"
2419
+ if st.get("denied") else "")])
2420
+ if j.get("not_retried"):
2421
+ rows.append(["not retried", j["not_retried"]])
2422
+ if j.get("retry_of"):
2423
+ rows.append(["retry of", f"job {j['retry_of']}"])
2424
+ if j.get("not_before") and j["state"] == "queued":
2425
+ rows.append(["retry at", datetime.fromtimestamp(j["not_before"]).strftime("%H:%M:%S")])
2426
+ if watched:
2427
+ rows.append(["watched by", ", ".join(f"{w['verb']} pid {w['pid']}" for w in watched)])
2428
+ if j.get("reported"):
2429
+ rows.append(["reported to", ", ".join(r["by"] for r in j["reported"])])
2430
+ print(ui.table(rows, indent=" "))
2431
+ for h in j.get("history") or []:
2432
+ print(f" attempt {h['attempt']}: rc={_rc(h)}" + (f" ({h['reason']})" if h.get("reason") else "")
2433
+ + f" after {ui.human_duration(h.get('seconds'))}")
2434
+ if j.get("notes"):
2435
+ print(ui.c("notes", "bold"))
2436
+ for n in j["notes"]:
2437
+ print(f" {datetime.fromtimestamp(n['t']):%m-%d %H:%M} {_text(n.get('by'))}: {_text(n['text'])}")
2438
+ if j.get("hooks"):
2439
+ print(ui.c("hooks", "bold"))
2440
+ for h in j["hooks"]:
2441
+ print(f" {datetime.fromtimestamp(h['t']):%m-%d %H:%M} {h['event']}: {_text(h['cmd'])} "
2442
+ f"log {_text(h['log'])}")
2443
+ print(ui.c("log", "bold") + f" {_text(j.get('log'))}")
2444
+ for line in log_tail(j, 20):
2445
+ print(" " + _text(line))
2446
+ return 0
2447
+
2448
+
2449
+ def cmd_note(a) -> int:
2450
+ j = _one(a.id, "note", a.scope)
2451
+ text = " ".join(a.text).strip()
2452
+ if not text:
2453
+ ui.die(f"{_PROG} note: no text", 2)
2454
+ add_note(j["id"], text, by=a.by)
2455
+ print(f"noted on {j['id']}")
2456
+ return 0
2457
+
2458
+
2459
+ def cmd_log(a) -> int:
2460
+ j = _one(a.id, "log", a.scope)
2461
+ path = j.get("log")
2462
+ if a.follow and j["state"] == "held":
2463
+ print(f"[{_PROG} log] note: {j['id']} is held, so this waits forever. "
2464
+ f"`pp {_PROG} release {j['id']}` releases it", file=sys.stderr)
2465
+ if a.follow:
2466
+ while not (path and os.path.exists(path)): # queued: the log appears when it starts
2467
+ cur = find(j["id"])
2468
+ if not cur or cur["state"] not in LIVE:
2469
+ break
2470
+ time.sleep(0.5)
2471
+ try: # opened once and read from there: `clean` may remove it at any moment, before or after
2472
+ f = open(path or "", "rb")
2473
+ except OSError:
2474
+ ui.die(f"{_PROG}: job {j['id']} has no log yet ({j['state']})")
2475
+ if not a.follow:
2476
+ with f:
2477
+ for line in _tail(f, a.lines):
2478
+ print(line)
2479
+ return 0
2480
+ with f: # the tail, then what follows it, from one open file: no line falls between
2481
+ f.seek(_tail_start(f, a.lines))
2482
+ while True:
2483
+ chunk = f.read()
2484
+ if chunk:
2485
+ sys.stdout.write(chunk.decode(errors="replace"))
2486
+ sys.stdout.flush()
2487
+ continue
2488
+ cur = find(j["id"])
2489
+ if not cur or cur["state"] in ENDED: # a pending retry is queued, not ended: keep following
2490
+ sys.stdout.write(f.read().decode(errors="replace"))
2491
+ print(_ended_line(cur, j["id"]), file=sys.stderr)
2492
+ return 0 if cur and cur["state"] == "done" else 1 # like wait
2493
+ time.sleep(0.5)
2494
+
2495
+
2496
+ def cmd_kill(a) -> int:
2497
+ """Ask for each job to be stopped, and report the outcome that is recorded for it.
2498
+
2499
+ A kill signals no job itself. It writes a stop on the job in one transaction and waits for the record of
2500
+ how the job ended, the way `wait` waits for an outcome. The queue's worker carries that stop out, so this
2501
+ makes sure the queue has one: it wakes the worker that holds the claim, or starts one where nobody does.
2502
+ That worker signals the job's process group, escalates to SIGKILL after the grace, reaps it and records
2503
+ how it ended, so one actor ends a job and its outcome is recorded once. A worker this started launches the
2504
+ queue's queued jobs too, as the worker an `add` starts does.
2505
+
2506
+ A job named as queued or held is refused, since it has no process: `drop` removes it. A job this kill read
2507
+ as running may be back in its queue for a retry by the time the stop is written. It has no process then,
2508
+ so the transaction ends it there."""
2509
+ rc = 0
2510
+ jobs = _resolve(a.ids, a.scope)
2511
+ go = _gate(a, jobs, "kill", ("running",))
2512
+ if go is not None:
2513
+ return go
2514
+ asked = [] # the jobs a worker has to stop, in the order they were named
2515
+ for j in jobs:
2516
+ jid = j["id"]
2517
+ if j["state"] != "running" and j["_bulk"]:
2518
+ continue # listed by _gate
2519
+ if j["state"] != "running":
2520
+ hint = f"`pp {_PROG} drop {jid}` removes it" if j["state"] in ("queued", "held") else "it has ended"
2521
+ ui.warn(f"{_PROG}: job {jid} is {j['state']}, not running; {hint}")
2522
+ rc = 2
2523
+ continue
2524
+ with txn() as s:
2525
+ cur = s.job(jid)
2526
+ if cur is None or cur["state"] in ENDED:
2527
+ print(f"job {jid} already {cur['state'] if cur else 'gone'}")
2528
+ continue
2529
+ if cur["state"] in ("queued", "held"):
2530
+ # it went back to its queue for a retry: no process exists, so this is where it ends
2531
+ was = cur["state"]
2532
+ _finish(s, cur, "failed", None, "killed")
2533
+ print(f"killed {jid} (it was {was}, with no process to stop) {_text(j.get('tag'))}")
2534
+ continue
2535
+ cur["stop"] = merge_stop(cur.get("stop"), {"by": _by(), "t": time.time(), "grace": float(a.grace)})
2536
+ s.save(cur)
2537
+ asked.append((j, cur["stop"]["t"]))
2538
+ for q in sorted({j["queue"] for j, _t in asked}):
2539
+ worker_up(q, wake=True) # the queue's worker is what carries these stops out
2540
+ for j, t in asked:
2541
+ rc = max(rc, _await_stop(j, float(a.grace), t))
2542
+ return rc
2543
+
2544
+
2545
+ def _await_stop(j: dict, grace: float, t: float) -> int:
2546
+ """Wait for the stop written on this job at t to be carried out, then print how the job ended. -> this
2547
+ job's exit status: 0 once it has ended, 1 when pp may not signal it, when the record stops carrying that
2548
+ stop, or when the job outlives the wait.
2549
+
2550
+ Every pass makes sure the queue has a worker. A pass that finds one up wakes it, so its next tick is this
2551
+ pass and what this waits for is the job's own exit time, not the queue's poll period. A pass that finds
2552
+ none starts a round of them, which `worker_up` does at most every START_EVERY, so a worker that cannot
2553
+ come up is not started on every pass. Such a round waits for each start in it to say it is ready, so a
2554
+ pass that makes one can take the wait past the job's exit time plus KILL_WAIT by those ready waits. The
2555
+ deadline is read again as soon as that round is over, and what this prints is the time it really waited,
2556
+ not the bound."""
2557
+ jid, started_at = j["id"], time.time()
2558
+ deadline = started_at + grace + KILL_WAIT
2559
+ while True:
2560
+ cur = find(jid)
2561
+ if cur is None:
2562
+ print(f"dropped {jid}")
2563
+ return 1
2564
+ stop = cur.get("stop") or {}
2565
+ if cur["state"] in ENDED:
2566
+ break
2567
+ if stop.get("t") != t:
2568
+ # the record no longer carries this stop, so nobody is going to carry it out: say where the job is
2569
+ where = f"it is {cur['state']}" + (f", attempt {cur['attempt']}" if cur.get("attempt", 1) > 1 else "")
2570
+ hint = f"`pp {_PROG} drop {jid}` removes it" if cur["state"] in ("queued", "held") else "ask again"
2571
+ ui.warn(f"{_PROG}: job {jid} no longer carries the stop it was asked for; {where}, so {hint}")
2572
+ return 1
2573
+ if stop.get("denied"):
2574
+ ui.warn(f"{_PROG}: job {jid}: not allowed to signal process group {cur.get('pgid')}; "
2575
+ "it is still running")
2576
+ return 1
2577
+ if time.time() > deadline:
2578
+ # what the queue's worker is says who has the stop now, and the record says what has gone out for
2579
+ # it already, whoever sent it. Neither answer names a log that does not exist
2580
+ q, waited = j["queue"], ui.human_duration(time.time() - started_at)
2581
+ went = ("the SIGKILL went out" if stop.get("kill9") else
2582
+ "the SIGTERM went out" if stop.get("sent") else "no signal of pp's has gone out")
2583
+ said = (f"{_PROG}: job {jid} is still running {waited} after it was asked to stop; {went}. "
2584
+ f"`pp {_PROG} show {jid}` says what went out for it.")
2585
+ if worker_now(q).up:
2586
+ ui.warn(f"{said} The stop went to the {q} worker on every pass, so this wait is not the "
2587
+ f"queue's poll period. The {q} worker's log: {_worker_log(q)}")
2588
+ else:
2589
+ ui.warn(f"{said} No worker of the {q} queue is up now, so nothing is carrying it out. The "
2590
+ f"{q} worker's log says why none came up: {_worker_log(q)}")
2591
+ return 1
2592
+ # a pass wakes the worker that is up, so its next tick is this pass and what this waits out is the
2593
+ # job's own exit time. Where none is up, worker_up starts one at most every START_EVERY
2594
+ worker_up(j["queue"], quiet=True, wake=True)
2595
+ if time.time() > deadline:
2596
+ continue # a round of starts took this pass past the deadline: look once more and report
2597
+ time.sleep(KILL_POLL)
2598
+ stop = cur.get("stop") or {}
2599
+ if not stop.get("sent"):
2600
+ print(f"job {jid} had already ended: {_ended_line(cur, jid)}")
2601
+ elif cur.get("rc") is not None:
2602
+ print(f"job {jid} ended on its own as it was being killed: {_ended_line(cur, jid)}")
2603
+ else:
2604
+ # the grace on the record, which is the shorter of every ask (`merge_stop`) and the one the SIGKILL
2605
+ # went out after, not the one this kill asked for
2606
+ how = (f"SIGKILL after {float(stop.get('grace') or 0):g}s" if stop.get("kill9")
2607
+ else "stopped on SIGTERM")
2608
+ print(f"killed {jid} ({how}) {_text(cur.get('tag'))}")
2609
+ return 0
2610
+
2611
+
2612
+ def _transition(a, src: tuple, dst: str | None, verb: str, done: str) -> int:
2613
+ rc, skipped, started = 0, [], set()
2614
+ jobs = _resolve(a.ids, a.scope)
2615
+ if verb == "drop":
2616
+ go = _gate(a, jobs, "drop", src)
2617
+ if go is not None:
2618
+ return go
2619
+ for j in jobs:
2620
+ with txn() as s:
2621
+ cur = s.job(j["id"])
2622
+ if cur is None or cur["state"] not in src:
2623
+ if j["_bulk"]:
2624
+ if verb != "drop": # drop's plan listed them already
2625
+ skipped.append(cur or {**j, "state": "gone"})
2626
+ continue
2627
+ st = cur["state"] if cur else "gone"
2628
+ hint = f". `pp {_PROG} kill {j['id']}` stops it" if st == "running" else ""
2629
+ ui.warn(f"{_PROG}: job {j['id']} is {st}; {verb} works on {'/'.join(src)} jobs{hint}")
2630
+ rc = 2
2631
+ continue
2632
+ if dst is None:
2633
+ _forget_job(s, cur) # the record, and every file that belongs to this job alone
2634
+ else:
2635
+ cur["state"] = dst
2636
+ s.save(cur)
2637
+ print(f"{done} {j['id']} {_text(j.get('tag'))}")
2638
+ if dst == "queued":
2639
+ started.add(cur["queue"])
2640
+ _left(skipped)
2641
+ if not getattr(a, "no_start", False): # a job added with --hold started no worker; one per queue
2642
+ for q in sorted(started):
2643
+ worker_up(q, wake=True)
2644
+ return rc
2645
+
2646
+
2647
+ def _reprio(a) -> int:
2648
+ rc, skipped = 0, []
2649
+ for j in _resolve(a.ids, a.scope):
2650
+ with txn() as s:
2651
+ cur = s.job(j["id"])
2652
+ if cur is None or cur["state"] not in ("queued", "held"):
2653
+ if j["_bulk"]:
2654
+ skipped.append(cur or {**j, "state": "gone"})
2655
+ continue
2656
+ ui.warn(f"{_PROG}: job {j['id']} is {cur['state'] if cur else 'gone'}; reprio works on queued/held jobs")
2657
+ rc = 2
2658
+ continue
2659
+ cur["prio"] = a.prio
2660
+ s.save(cur)
2661
+ print(f"reprio {j['id']} -> p{a.prio}")
2662
+ _left(skipped)
2663
+ return rc
2664
+
2665
+
2666
+ CARRY = ("queue", "tag", "project", "cwd", "cmd", "runner", "env", "shell_env", "needs", "prio", "owner", "timeout",
2667
+ "retries", "retry_delay", "retry_if", "on_fail", "on_done")
2668
+
2669
+
2670
+ def cmd_retry(a) -> int:
2671
+ started, rc, skipped = set(), 0, []
2672
+ for j in _resolve(a.ids, a.scope):
2673
+ if j["state"] not in ENDED and j["_bulk"]:
2674
+ skipped.append(j)
2675
+ continue
2676
+ if j["state"] not in ENDED:
2677
+ ui.warn(f"{_PROG}: job {j['id']} is {j['state']}; retry re-queues ended jobs")
2678
+ rc = 2
2679
+ continue
2680
+ new = {k: j[k] for k in CARRY if k in j}
2681
+ new.update(retry_of=j["id"], attempt=1)
2682
+ if a.prio is not None:
2683
+ new["prio"] = a.prio
2684
+ with txn() as s:
2685
+ jid = s.add(new, "queued")["id"]
2686
+ print(jid)
2687
+ print(f"pp {_PROG}: retry of {j['id']} queued as {jid} ({j['queue']})", file=sys.stderr)
2688
+ started.add(j["queue"])
2689
+ _left(skipped)
2690
+ if not a.no_start:
2691
+ for q in sorted(started):
2692
+ worker_up(q, wake=True)
2693
+ return rc
2694
+
2695
+
2696
+ def cmd_clean(a, scope: list[str] | None) -> int:
2697
+ cutoff = time.time() - _dur_arg(a.older, "--older", "d") if a.older else None
2698
+ victims = [j for j in _jobs(ENDED, scope) if cutoff is None or (j.get("ended") or 0) < cutoff]
2699
+ if not victims:
2700
+ print("nothing to clean")
2701
+ return 0
2702
+ print(f"{len(victims)} ended job record(s)" + (f" older than {a.older}" if a.older else "")
2703
+ + ", with their logs")
2704
+ for q in sorted({j["queue"] for j in victims}):
2705
+ print(f" {q}: {_ranges(sorted(j['id'] for j in victims if j['queue'] == q))}")
2706
+ if a.dry_run:
2707
+ return 0
2708
+ if not ui.confirm("remove them?", a.yes):
2709
+ return 2
2710
+ removed = 0
2711
+ for batch in (victims[i:i + CLEAN_BATCH] for i in range(0, len(victims), CLEAN_BATCH)):
2712
+ with txn() as s: # a batch at a time: a reader waits for the transaction, so it stays short
2713
+ for v in batch:
2714
+ j = s.job(v["id"], must_be=ENDED)
2715
+ if j is None:
2716
+ continue
2717
+ _forget_job(s, j)
2718
+ removed += 1
2719
+ print(f"removed {removed}")
2720
+ return 0
2721
+
2722
+
2723
+ def _event(prev: dict | None, j: dict | None, jid: int) -> str | None:
2724
+ """watch: the lines for a change of a job (start, retry, hold, release), before its outcome; None when nothing
2725
+ changed. A poll can miss steps: a held job seen running or ended was released, and a job seen ended that was
2726
+ held or queued was started, so those lines come too."""
2727
+ if j is None or prev is None:
2728
+ return None
2729
+ st, k, was = j["state"], j.get("attempt", 1), prev["state"]
2730
+ if (st, k) == (was, prev.get("attempt", 1)):
2731
+ return None
2732
+ tag = _text(j.get("tag"))
2733
+ started = f"{jid} started{' ' + _attempt_str(j) if _attempt_str(j) else ''} {tag}"
2734
+ lines = [f"{jid} released {tag}"] if was == "held" and st != "held" else []
2735
+ if st in ENDED:
2736
+ lines += [started] if was in ("held", "queued") else []
2737
+ elif st == "running":
2738
+ lines.append(started)
2739
+ elif st == "queued" and k > prev.get("attempt", 1):
2740
+ h = (j.get("history") or [{}])[-1]
2741
+ why = h.get("reason") or f"exit {h.get('rc')}"
2742
+ left = _in_delay(j.get("not_before"))
2743
+ lines.append(f"{jid} attempt {k - 1}/{int(j.get('retries') or 0) + 1} failed ({why}); attempt {k} in {left} {tag}")
2744
+ elif not lines:
2745
+ lines.append(f"{jid} {st} {tag}")
2746
+ return "\n".join(lines) or None
2747
+
2748
+
2749
+ def cmd_wait(a, scope: list[str] | None, stream: bool = False) -> int:
2750
+ """wait: block until jobs end, then print their outcomes. watch (stream): a line per event as it happens.
2751
+ Both hold a watcher lease while they run (`show` lists it) and stamp each outcome they print on the job."""
2752
+ verb = "watch" if stream else "wait"
2753
+ t0 = time.time()
2754
+
2755
+ def say(*lines):
2756
+ for line in lines:
2757
+ print(line, flush=True)
2758
+
2759
+ def timed_out():
2760
+ return a.timeout is not None and time.time() - t0 >= a.timeout
2761
+
2762
+ def footer():
2763
+ with listing() as s:
2764
+ live = {st: len(s.jobs(st, scope)) for st in LIVE}
2765
+ say(f"[{_PROG} {verb}] {datetime.now():%H:%M:%S}: running {live['running']}, queued {live['queued']}, held {live['held']}")
2766
+
2767
+ def tell(j, jid):
2768
+ say(*_outcome_lines(j, jid, a.tail))
2769
+ if j is not None:
2770
+ _stamp_reported(jid, verb)
2771
+
2772
+ if a.idle:
2773
+ before = {j["id"] for j in _jobs(ENDED, scope)}
2774
+ told: set = set()
2775
+ asked: set = set() # the queues this loop has asked for a worker: it says what holds a claim once
2776
+ with watching(None, scope, verb):
2777
+ while True:
2778
+ # one listing: the jobs still to come and the ones that ended are read together, so the last
2779
+ # look, the one that finds none live, holds every outcome there is to report
2780
+ with listing() as s:
2781
+ live = s.jobs(("queued", "running"), scope)
2782
+ ended = [j for j in s.jobs(ENDED, scope) if j["id"] not in before]
2783
+ if not live:
2784
+ break
2785
+ if stream:
2786
+ for j in sorted(ended, key=lambda j: j["id"]):
2787
+ if j["id"] not in told:
2788
+ told.add(j["id"])
2789
+ tell(j, j["id"])
2790
+ if not a.no_start: # running ones too: only a worker records how a job ended
2791
+ for q in {j["queue"] for j in live}:
2792
+ worker_up(q, quiet=q in asked)
2793
+ asked.add(q)
2794
+ if timed_out():
2795
+ print(f"[{_PROG} {verb}] timeout after {a.timeout:g}s", file=sys.stderr)
2796
+ return 3
2797
+ time.sleep(a.poll)
2798
+ for j in sorted(ended, key=lambda j: j["id"]):
2799
+ if j["id"] not in told:
2800
+ tell(j, j["id"])
2801
+ footer()
2802
+ return 1 if any(j["state"] == "failed" for j in ended) else 0
2803
+ if a.ids:
2804
+ targets, need_all = [j["id"] for j in _resolve(a.ids, scope)], True
2805
+ elif a.mine:
2806
+ me = session.caller()
2807
+ if not me:
2808
+ ui.die(f"{_PROG} {verb} --mine: not inside an agent session (no CLAUDE_CODE_SESSION_ID)", 2)
2809
+
2810
+ def mine(j):
2811
+ return isinstance(j.get("owner"), dict) and j["owner"].get("session") == me["session"]
2812
+
2813
+ def unheard(j): # ended before this session waited on it: a quick failure must not be missed
2814
+ return not any(isinstance(r, dict) and r.get("session") == me["session"] for r in j.get("reported") or [])
2815
+
2816
+ # one listing of both halves: a job is listed once, in one state, so a target is named once
2817
+ with listing() as s:
2818
+ targets = [j["id"] for j in s.jobs(LIVE, scope) if mine(j)] \
2819
+ + [j["id"] for j in s.jobs(ENDED, scope) if mine(j) and unheard(j)]
2820
+ need_all = True
2821
+ if not targets:
2822
+ say("nothing of this session's is queued, held or running, and every outcome was reported to it")
2823
+ return 0
2824
+ else:
2825
+ targets, need_all = [j["id"] for j in _jobs(("queued", "running"), scope)], False
2826
+ if not targets:
2827
+ say("nothing queued or running")
2828
+ return 0
2829
+ held = [j["id"] for j in (find(t) for t in targets) if j and j["state"] == "held"]
2830
+ if held:
2831
+ print(f"[{_PROG} {verb}] note: {', '.join(map(str, held))} held, so this waits forever. "
2832
+ f"`pp {_PROG} release` them", file=sys.stderr)
2833
+ with watching(targets, None, verb):
2834
+ prev: dict = {}
2835
+ told: set = set()
2836
+ asked = set() # as above: a loop says what holds a queue's claim on its first pass only
2837
+ blamed: set = set() # the queues this loop has said no worker came up for, which it says once
2838
+ while True:
2839
+ cur = {t: find(t) for t in targets}
2840
+ if stream:
2841
+ if not prev:
2842
+ say(f"[{_PROG} watch] " + ", ".join(f"{t} {cur[t]['state'] if cur[t] else 'gone'}" for t in targets))
2843
+ for t in targets:
2844
+ line = _event(prev.get(t), cur[t], t)
2845
+ if line:
2846
+ say(line)
2847
+ if t not in told and (cur[t] is None or cur[t]["state"] in ENDED):
2848
+ told.add(t)
2849
+ tell(cur[t], t)
2850
+ prev = {t: j for t, j in cur.items() if j}
2851
+ ended = {t: j for t, j in cur.items() if j is None or j["state"] in ENDED}
2852
+ if ended and (len(ended) == len(targets) or not need_all):
2853
+ for t in sorted(ended):
2854
+ if t not in told:
2855
+ tell(ended[t], t)
2856
+ return 1 if any(j is None or j["state"] == "failed" for j in ended.values()) else 0
2857
+ if not a.no_start: # a dead worker would otherwise make this wait forever, queued or running:
2858
+ for q in {j["queue"] for j in cur.values() if j and j["state"] in ("queued", "running")}:
2859
+ quietly = stream or q in asked
2860
+ w = worker_up(q, quiet=quietly) # only a worker records how a job ended
2861
+ asked.add(q)
2862
+ if w.why and q not in blamed:
2863
+ # `watch` asks quietly on every pass, so a queue no worker can come up for would
2864
+ # leave it printing events forever and never the reason none launches the job
2865
+ blamed.add(q)
2866
+ if quietly:
2867
+ say(f"[{_PROG} {verb}] the {q} worker did not come up: {w.why}; "
2868
+ f"its log: {_worker_log(q)}")
2869
+ if timed_out():
2870
+ print(f"[{_PROG} {verb}] timeout after {a.timeout:g}s: {len(targets) - len(ended)} of {len(targets)} not ended",
2871
+ file=sys.stderr)
2872
+ return 3
2873
+ time.sleep(a.poll)
2874
+
2875
+
2876
+ def cmd_worker(a, q_given: str | None, default_queue: str, scope: list[str] | None) -> int:
2877
+ """`worker status`, `worker start` and `worker stop`, each through the one construct that says what a
2878
+ queue's worker is.
2879
+
2880
+ `worker status --json` is a list, one entry per queue in scope, and this is its shape, stated once:
2881
+
2882
+ queue the queue's name
2883
+ pid the pid of its worker; null where none is up and while one that is starting names nothing yet
2884
+ how "up" (a worker holds the claim and has recorded itself), "down" (nobody holds it) or
2885
+ "starting" (a worker holds it and has not recorded itself yet, which is a worker's first
2886
+ moment). "started" is `worker start`'s answer and never appears here, since this starts none
2887
+ up whether a worker is up, which is `pid` being a number
2888
+ log where that worker writes
2889
+
2890
+ A field change goes in CHANGELOG.md (CONTRIBUTING.md, "Adding a command").
2891
+ """
2892
+ if a.action == "status":
2893
+ qs = [q_given] if q_given else _scope_list(scope)
2894
+ rows = []
2895
+ for q in qs:
2896
+ w = worker_now(q)
2897
+ rows.append({"queue": q, "pid": w.pid, "how": w.how, "log": _worker_log(q)})
2898
+ if a.json:
2899
+ ui.print_json([{**r, "up": r["pid"] is not None} for r in rows])
2900
+ elif not rows:
2901
+ print("no queues yet")
2902
+ for r in [] if a.json else rows:
2903
+ state = (f"worker up (pid {r['pid']})" if r["pid"] is not None
2904
+ else "worker starting" if r["how"] == STARTING else "worker down")
2905
+ print(f"{r['queue']}: {state} log {r['log']}")
2906
+ return 0
2907
+ q = q_given or default_queue
2908
+ writing_queue(q) # `worker start -q NAME` opens that queue, so it goes through the same refusal
2909
+ logf = _worker_log(q)
2910
+ if a.action == "start":
2911
+ # one answer, from the scope that acted on it: nothing looks at the claim again, so a worker that came
2912
+ # up between two looks can never read as one that never came
2913
+ w = worker_up(q, quiet=True)
2914
+ if w.how == STARTED:
2915
+ print(f"{q}: started worker (pid {w.pid}; log {logf})")
2916
+ elif w.up:
2917
+ print(f"{q}: worker already up (pid {w.pid})")
2918
+ elif w.how == STARTING:
2919
+ ui.die(f"{_PROG}: {_starting(q)}; ask again", 1)
2920
+ else:
2921
+ # this asked quietly, so the reason the answer carries is said here: a start that timed out
2922
+ # logged nothing of its own, and that reason is the only word there is about it
2923
+ why = f": {w.why}" if w.why else ""
2924
+ ui.die(f"{_PROG}: the {q} worker did not come up{why}; its log: {logf}")
2925
+ return 0
2926
+ w = worker_now(q) # one look, and this stop signals the worker that look found
2927
+ if w.how == STARTING: # there is nothing to signal yet: it has not recorded itself
2928
+ ui.die(f"{_PROG}: {_starting(q)}; ask again", 1)
2929
+ if not w.up: # nothing holds the claim: there is no worker, whatever is written in it
2930
+ print(f"{q}: worker down")
2931
+ return 0
2932
+ h = w.handle # the handle this one look gave: no second look decides what is signalled
2933
+ pid = h.id
2934
+ outcome = proc.signal_pid(h, signal.SIGTERM)
2935
+ if outcome == "gone": # it exited since we looked, or that pid is another process now
2936
+ print(f"{q}: worker down")
2937
+ return 0
2938
+ if outcome == "denied":
2939
+ ui.die(f"{_PROG}: not allowed to stop the {q} worker (pid {pid}); it belongs to another user", 1)
2940
+ # it stops between ticks, and one tick can wait on a probe (up to 60s each) or nvidia-smi; it is down when
2941
+ # the claim is no longer its own, which the kernel sees to even if it never reaches the end of its own stop.
2942
+ # What this waits for is that worker, not a free claim: an `add` or a background `wait` starts the next
2943
+ # worker as soon as the claim is free, and this stop is over when the one it signalled has let it go.
2944
+ def it_still_holds() -> bool:
2945
+ """Whether the worker this stop signalled still holds the claim, asked through the one construct. A
2946
+ record naming another worker, and nothing holding the claim, are both that worker gone."""
2947
+ held = worker_now(q).rec or {}
2948
+ return (held.get("pid"), held.get("pstart")) == (h.id, h.start)
2949
+
2950
+ start, said = time.time(), False
2951
+ while it_still_holds():
2952
+ if not said and time.time() - start > 10:
2953
+ print(f"{q}: worker {pid} is finishing its tick (a probe or nvidia-smi); waiting", flush=True)
2954
+ said = True
2955
+ if time.time() - start > STOP_WAIT:
2956
+ ui.die(f"{_PROG}: the {q} worker ({pid}) did not stop within {ui.human_duration(STOP_WAIT)}; its log: {logf}")
2957
+ time.sleep(0.1)
2958
+ print(f"{q}: stopped worker {pid}; running jobs keep running and the next worker adopts them")
2959
+ return 0
2960
+
2961
+
2962
+ # ---- the CLI ----------------------------------------------------------------------------------------
2963
+
2964
+ def _parser(prog: str, doc: str, add_args=None):
2965
+ ap = ui.parser(prog, doc)
2966
+ sub = ap.add_subparsers(dest="verb", metavar="VERB")
2967
+
2968
+ def sp(name, **kw):
2969
+ p = sub.add_parser(name, **kw)
2970
+ p.add_argument("-q", "--queue", help="the named queue (see `pp help queue`)")
2971
+ return p
2972
+
2973
+ p = sp("add", help="queue a command (after --)")
2974
+ p.add_argument("--tag", help="a name to select it by, alone or with the other jobs that share it "
2975
+ "(default: the command, cut to 40 characters)")
2976
+ p.add_argument("--prio", type=int, default=10, help="lower runs first (default 10)")
2977
+ p.add_argument("--gpu-gb", type=float, help="GPU memory to reserve on one card (the job's peak use, plus a margin)")
2978
+ p.add_argument("--cpu", type=float, help="CPUs to reserve")
2979
+ p.add_argument("--mem-gb", type=float, help="RAM to reserve, in GB")
2980
+ p.add_argument("--need", action="append", metavar="NAME=N", help="a counter from [resources] (every queue's) or [queue.Q.resources] (one queue's own)")
2981
+ p.add_argument("--env", action="append", metavar="K=V",
2982
+ help="set in the job's environment; wins over the project's [queue] env")
2983
+ p.add_argument("--cwd", help="run there (default: here); also picks the project")
2984
+ p.add_argument("--timeout", metavar="DUR", help="kill it after DUR (90s, 30m, 2h, 1d); none: no timeout")
2985
+ p.add_argument("--retries", type=int, help="automatic retries of a failure (default 0)")
2986
+ p.add_argument("--retry-delay", metavar="DUR", help="delay before the first retry, doubling after (default 30s)")
2987
+ p.add_argument("--retry-if", metavar="REGEX", help="retry only failures whose reason or log tail matches")
2988
+ p.add_argument("--on-fail", metavar="CMD", help="shell command run on the final failure; none: no hook")
2989
+ p.add_argument("--on-done", metavar="CMD", help="shell command run on success; none: no hook")
2990
+ p.add_argument("--hold", action="store_true", help="park it as held until released")
2991
+ p.add_argument("--no-start", action="store_true", help="do not start a worker")
2992
+ p.add_argument("--from", dest="from_", metavar="FILE", help="one command per line; - for stdin")
2993
+ if add_args:
2994
+ add_args(p)
2995
+ p.add_argument("cmd", nargs="...")
2996
+ for name in ("status", "ls"):
2997
+ p = sp(name, help="running, queued, held, recent" if name == "status" else "same as status")
2998
+ p.add_argument("--json", action="store_true")
2999
+ p.add_argument("-n", type=int, default=10, help="recent jobs shown")
3000
+ g = p.add_mutually_exclusive_group()
3001
+ g.add_argument("--here", action="store_true", help="only this directory's project")
3002
+ g.add_argument("--project")
3003
+ p = sp("show", help="every field of a job, and its log tail")
3004
+ p.add_argument("id")
3005
+ p.add_argument("--json", action="store_true")
3006
+ p = sp("note", help="attach a note to a job (any state)")
3007
+ p.add_argument("id")
3008
+ p.add_argument("text", nargs="+")
3009
+ p.add_argument("--by", metavar="NAME", help="the author shown (default: the calling agent session, else user)")
3010
+ p = sp("log", help="tail a job's log")
3011
+ p.add_argument("id")
3012
+ p.add_argument("-f", "--follow", action="store_true")
3013
+ p.add_argument("-n", "--lines", type=int, default=40)
3014
+ p = sp("kill", help="stop running jobs: the whole process group is signalled; a job the signal stopped "
3015
+ "fires no hook")
3016
+ p.add_argument("ids", nargs="+")
3017
+ p.add_argument("--grace", type=float, default=10, help="seconds between SIGTERM and SIGKILL")
3018
+ p.add_argument("-n", "--dry-run", action="store_true", help="only show which jobs it would stop")
3019
+ p.add_argument("-y", "--yes", action="store_true", help="do not ask about a range or a shared tag")
3020
+ for name, hlp in (("drop", "remove queued/held jobs"), ("hold", "park queued jobs"), ("release", "un-park held jobs")):
3021
+ p = sp(name, help=hlp)
3022
+ p.add_argument("ids", nargs="+")
3023
+ if name == "drop":
3024
+ p.add_argument("-n", "--dry-run", action="store_true", help="only show which jobs it would remove")
3025
+ p.add_argument("-y", "--yes", action="store_true", help="do not ask about a range or a shared tag")
3026
+ if name == "release":
3027
+ p.add_argument("--no-start", action="store_true", help="do not start a worker")
3028
+ p = sp("reprio", help="change a queued job's priority")
3029
+ p.add_argument("ids", nargs="+", metavar="ID")
3030
+ p.add_argument("prio", type=int)
3031
+ p = sp("retry", help="re-queue ended jobs as new jobs")
3032
+ p.add_argument("ids", nargs="+")
3033
+ p.add_argument("--prio", type=int)
3034
+ p.add_argument("--no-start", action="store_true")
3035
+ p = sp("clean", help="remove ended job records, with the logs that belong to them")
3036
+ p.add_argument("--older", metavar="AGE", help="only those ended before AGE ago (30m, 12h, 7d)")
3037
+ p.add_argument("-n", "--dry-run", action="store_true")
3038
+ p.add_argument("-y", "--yes", action="store_true")
3039
+ for name, hlp in (("wait", "block until jobs end, then print the outcomes (exit 0 ok, 1 failed or dropped, 3 timeout)"),
3040
+ ("watch", "a line per event (start, retry, outcome) as it happens; exits like wait")):
3041
+ p = sp(name, help=hlp)
3042
+ p.add_argument("ids", nargs="*")
3043
+ g = p.add_mutually_exclusive_group()
3044
+ g.add_argument("--idle", action="store_true", help="until nothing is queued or running")
3045
+ g.add_argument("--mine", action="store_true", help="every job this agent session queued that is live, or ended without a wait/watch of this session reporting it")
3046
+ p.add_argument("--timeout", metavar="DUR", type=lambda v: _dur_arg(v, "--timeout"),
3047
+ help="give up after DUR (90s, 30m, 2h, 1d; a bare number is seconds) and exit 3")
3048
+ p.add_argument("--poll", type=float, default=2.0, help="seconds between looks (default 2)")
3049
+ p.add_argument("--tail", type=int, default=15, help="log lines shown with a failure (default 15)")
3050
+ p.add_argument("--no-start", action="store_true", help="do not start a worker for queued jobs")
3051
+ p = sp("work", help="run a queue's worker in the foreground")
3052
+ p.add_argument("--backfill", action="store_true", default=None, help="start later jobs that fit while the head waits")
3053
+ p.add_argument("--poll", type=float)
3054
+ p = sp("worker", help="start | stop | status of the background workers")
3055
+ p.add_argument("action", choices=("start", "stop", "status"))
3056
+ p.add_argument("--json", action="store_true", help="status as JSON")
3057
+ return ap
3058
+
3059
+
3060
+ def run_cli(argv: list[str], prog: str, default_queue: str, doc: str, prepare_job=None, add_args=None) -> int:
3061
+ """The queue CLI for `pp <prog>`. default_queue receives `add` and `work` without -q; status, wait --idle, clean
3062
+ and worker status cover every queue. prepare_job(job, args) amends each job at add time; add_args(parser) adds
3063
+ options to `add`."""
3064
+ global _PROG
3065
+ _PROG = prog
3066
+ argv = list(argv)
3067
+ q_top = None
3068
+ if len(argv) >= 2 and argv[0] in ("-q", "--queue"):
3069
+ q_top, argv = argv[1], argv[2:]
3070
+ if not argv or (argv[0].startswith("-") and argv[0] not in ("-h", "--help")):
3071
+ argv = ["status"] + argv
3072
+ cmd: list[str] = []
3073
+ if argv[0] == "add" and "--" in argv:
3074
+ i = argv.index("--")
3075
+ argv, cmd = argv[:i], argv[i + 1:]
3076
+ a = _parser(prog, doc, add_args).parse_args(argv)
3077
+ q = getattr(a, "queue", None) or q_top
3078
+ if q is not None and (not NAME_RE.fullmatch(q) or q in RESERVED):
3079
+ ui.die(f"{prog}: '{q}' is not a queue name (lowercase letters, digits, - and _; not {' or '.join(RESERVED)})", 2)
3080
+ scope = a.scope = [q] if q else None
3081
+ if q is not None:
3082
+ open_queue(q) # a queue named with -q is opened once here, whatever the verb does with it
3083
+ if a.verb == "add":
3084
+ if cmd and a.cmd:
3085
+ ui.die(f"{prog} add: put the whole command after --", 2)
3086
+ return cmd_add(a, cmd or a.cmd, q or default_queue, prepare_job)
3087
+ if a.verb in ("status", "ls"):
3088
+ return cmd_status(a, scope)
3089
+ verbs = {"show": cmd_show, "note": cmd_note, "log": cmd_log, "kill": cmd_kill, "reprio": _reprio, "retry": cmd_retry}
3090
+ if a.verb in verbs:
3091
+ return verbs[a.verb](a)
3092
+ if a.verb == "drop":
3093
+ return _transition(a, ("queued", "held"), None, "drop", "dropped")
3094
+ if a.verb == "hold":
3095
+ return _transition(a, ("queued",), "held", "hold", "held")
3096
+ if a.verb == "release":
3097
+ return _transition(a, ("held",), "queued", "release", "released")
3098
+ if a.verb == "clean":
3099
+ return cmd_clean(a, scope)
3100
+ if a.verb in ("wait", "watch"):
3101
+ return cmd_wait(a, scope, stream=a.verb == "watch")
3102
+ if a.verb == "work":
3103
+ return work(q or default_queue, a.backfill, a.poll)
3104
+ if a.verb == "worker":
3105
+ return cmd_worker(a, q, default_queue, scope)
3106
+ return 2