outerloop-science 0.1.0.dev0__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.
Files changed (52) hide show
  1. outerloop/__init__.py +18 -0
  2. outerloop/__main__.py +3 -0
  3. outerloop/appauth.py +213 -0
  4. outerloop/appmanifest.py +198 -0
  5. outerloop/attempt.py +3481 -0
  6. outerloop/brief.py +515 -0
  7. outerloop/cli.py +439 -0
  8. outerloop/climbboard.py +1145 -0
  9. outerloop/compute.py +482 -0
  10. outerloop/contract.py +483 -0
  11. outerloop/contract_cli.py +63 -0
  12. outerloop/disk.py +164 -0
  13. outerloop/dispatch.py +586 -0
  14. outerloop/followup.py +2143 -0
  15. outerloop/github.py +1486 -0
  16. outerloop/harness.py +1449 -0
  17. outerloop/housekeeping.py +167 -0
  18. outerloop/init.py +313 -0
  19. outerloop/intake.py +129 -0
  20. outerloop/limits.py +80 -0
  21. outerloop/markers.py +48 -0
  22. outerloop/measure.py +523 -0
  23. outerloop/orchestrator.py +1901 -0
  24. outerloop/panel.py +188 -0
  25. outerloop/paths.py +27 -0
  26. outerloop/posting.py +160 -0
  27. outerloop/progress.py +170 -0
  28. outerloop/py.typed +0 -0
  29. outerloop/review.py +611 -0
  30. outerloop/review_agent.py +263 -0
  31. outerloop/review_agent_cli.py +209 -0
  32. outerloop/review_post_cli.py +162 -0
  33. outerloop/review_summarize_cli.py +163 -0
  34. outerloop/role_runner.py +229 -0
  35. outerloop/roles.py +247 -0
  36. outerloop/rolespec.py +89 -0
  37. outerloop/runstate.py +385 -0
  38. outerloop/steward.py +852 -0
  39. outerloop/style.py +12 -0
  40. outerloop/syscall.py +977 -0
  41. outerloop/syscall_cli.py +531 -0
  42. outerloop/tick.py +3166 -0
  43. outerloop/verifier.py +403 -0
  44. outerloop/verify_agent.py +149 -0
  45. outerloop/verify_agent_cli.py +95 -0
  46. outerloop/verify_post_cli.py +116 -0
  47. outerloop_science-0.1.0.dev0.dist-info/METADATA +145 -0
  48. outerloop_science-0.1.0.dev0.dist-info/RECORD +52 -0
  49. outerloop_science-0.1.0.dev0.dist-info/WHEEL +4 -0
  50. outerloop_science-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  51. outerloop_science-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  52. outerloop_science-0.1.0.dev0.dist-info/licenses/NOTICE +5 -0
outerloop/compute.py ADDED
@@ -0,0 +1,482 @@
1
+ """Compute behind one small interface: submit, status, cancel.
2
+
3
+ Everything the loop knows about compute goes through these verbs, so a
4
+ backend is one implementation: `SlurmCompute` submits real cluster jobs;
5
+ `LocalCompute` runs the same job specs as subprocesses in the current
6
+ allocation. A CI runner or a cloud/GPU-rental backend would be another
7
+ implementation of the same verbs — the callers never change.
8
+
9
+ The status query preserves a distinction the fail-safe design depends on
10
+ (docs/design/architecture.md, "Wake delivery and fail-safety"): a FAILED
11
+ query ("Slurm unknown") is not the same as a successful query that finds
12
+ nothing ("job gone") — misreading an outage as a vanished job would
13
+ terminate healthy runs.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import logging
20
+ import os
21
+ import shlex
22
+ import signal
23
+ import subprocess
24
+ import time
25
+ from collections.abc import Callable, Sequence
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Protocol
29
+
30
+ log = logging.getLogger(__name__)
31
+
32
+ # Terminal Slurm states (prefix-matched: sacct reports e.g. "CANCELLED by 123").
33
+ TERMINAL_STATES = (
34
+ "COMPLETED",
35
+ "FAILED",
36
+ "CANCELLED",
37
+ "TIMEOUT",
38
+ "OUT_OF_MEMORY",
39
+ "NODE_FAIL",
40
+ "PREEMPTED",
41
+ "BOOT_FAIL",
42
+ "DEADLINE",
43
+ )
44
+ # A successful query that returns no record: the job left Slurm's memory.
45
+ GONE = "GONE"
46
+
47
+
48
+ class SlurmError(RuntimeError):
49
+ """A Slurm command failed (submit/cancel), with its stderr."""
50
+
51
+
52
+ class SlurmQueryError(RuntimeError):
53
+ """A status query failed — the answer is UNKNOWN, not 'job gone'.
54
+
55
+ Callers must treat this as "defer and retry", never as a terminal state.
56
+ """
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class CommandResult:
61
+ returncode: int
62
+ stdout: str
63
+ stderr: str
64
+
65
+
66
+ Runner = Callable[[Sequence[str], int], CommandResult]
67
+
68
+
69
+ def _subprocess_runner(argv: Sequence[str], timeout_s: int) -> CommandResult:
70
+ completed = subprocess.run(list(argv), capture_output=True, text=True, timeout=timeout_s)
71
+ return CommandResult(completed.returncode, completed.stdout, completed.stderr)
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class JobSpec:
76
+ """One sbatch submission. `command` is run via --wrap; a script path can
77
+ be passed as `script` instead (mutually exclusive).
78
+
79
+ --wrap executes under a shell on the compute node: `command` must be
80
+ built from trusted parts, with anything variable passed through
81
+ `quote_command`. Never interpolate agent- or contract-supplied text."""
82
+
83
+ job_name: str
84
+ account: str
85
+ partition: str
86
+ time_minutes: int
87
+ command: str = ""
88
+ script: str = ""
89
+ script_args: tuple[str, ...] = ()
90
+ cpus: int = 1
91
+ mem: str = "2G"
92
+ gpus: int = 0
93
+ qos: str = ""
94
+ output: str = "/dev/null"
95
+ # Slurm scheduling controls
96
+ dependency: str = "" # e.g. "afterany:12345" or "singleton"
97
+ begin: str = "" # e.g. "now+30" or an absolute "YYYY-MM-DDTHH:MM:SS"
98
+ extra: tuple[str, ...] = ()
99
+
100
+ def to_argv(self) -> list[str]:
101
+ if bool(self.command) == bool(self.script):
102
+ raise ValueError("exactly one of command/script must be set")
103
+ argv = [
104
+ "sbatch",
105
+ "--parsable",
106
+ f"--job-name={self.job_name}",
107
+ f"--account={self.account}",
108
+ f"--time={self.time_minutes}",
109
+ f"--cpus-per-task={self.cpus}",
110
+ f"--mem={self.mem}",
111
+ f"--output={self.output}",
112
+ ]
113
+ if self.partition: # unset lets Slurm pick its default partition
114
+ argv.append(f"--partition={self.partition}")
115
+ if self.gpus:
116
+ # per-NODE, not per-job (--gpus): every job here is single-node,
117
+ # and Slurm submit plugins commonly classify a job by its
118
+ # per-node GRES — the per-job form has been rejected on a GPU
119
+ # partition as "CPU job setup is not valid"
120
+ argv.append(f"--gpus-per-node={self.gpus}")
121
+ if self.qos:
122
+ argv.append(f"--qos={self.qos}")
123
+ if self.dependency:
124
+ argv.append(f"--dependency={self.dependency}")
125
+ if self.begin:
126
+ argv.append(f"--begin={self.begin}")
127
+ argv.extend(self.extra)
128
+ if self.command:
129
+ argv.append(f"--wrap={self.command}")
130
+ else:
131
+ argv.append(self.script)
132
+ argv.extend(self.script_args)
133
+ return argv
134
+
135
+
136
+ class Compute(Protocol):
137
+ """The verbs every compute backend implements. Callers (the measurer, the
138
+ launcher, the wake dispatcher) depend on this, never on a backend."""
139
+
140
+ def submit(self, spec: JobSpec) -> str: ...
141
+ def status(self, job_id: str) -> str: ...
142
+ def pending_reason(self, job_id: str) -> str: ...
143
+ def job_partition(self, job_id: str) -> str: ...
144
+ def active_job_names(self) -> list[str]: ...
145
+ def job_id_for_name(self, name: str) -> str: ...
146
+ def cancel(self, job_id: str) -> None: ...
147
+
148
+
149
+ def local_mode() -> bool:
150
+ """AUTORESEARCH_COMPUTE=local selects the monolith: every job a
151
+ synchronous subprocess of the caller (docs/design/onboarding.md) — the
152
+ zero-cluster on-ramp and the paper's serialized-baseline ablation. Any
153
+ other value (or none) is Slurm. This helper is the only reader of the
154
+ env var, so mode checks cannot drift."""
155
+ return os.environ.get("AUTORESEARCH_COMPUTE", "").strip().lower() == "local"
156
+
157
+
158
+ def compute_from_env() -> SlurmCompute | LocalCompute:
159
+ """The deployment's compute backend, per `local_mode`."""
160
+ return LocalCompute() if local_mode() else SlurmCompute()
161
+
162
+
163
+ @dataclass
164
+ class SlurmCompute:
165
+ """The three verbs, plus afterany for wake jobs."""
166
+
167
+ runner: Runner = field(default=_subprocess_runner)
168
+ command_timeout_s: int = 60
169
+
170
+ def submit(self, spec: JobSpec) -> str:
171
+ """Submit; returns the job id. Raises SlurmError on failure."""
172
+ result = self.runner(spec.to_argv(), self.command_timeout_s)
173
+ if result.returncode != 0:
174
+ raise SlurmError(f"sbatch failed ({result.returncode}): {result.stderr.strip()}")
175
+ job_id = result.stdout.strip().split(";")[0]
176
+ if not job_id.isdigit():
177
+ raise SlurmError(f"sbatch returned no job id: {result.stdout.strip()!r}")
178
+ log.info("submitted %s as job %s", spec.job_name, job_id)
179
+ return job_id
180
+
181
+ def status(self, job_id: str) -> str:
182
+ """The job's Slurm state, or GONE when a *successful* query finds no
183
+ record. Raises SlurmQueryError when the query itself fails."""
184
+ if not job_id.isdigit():
185
+ raise ValueError(f"not a job id: {job_id!r}")
186
+ try:
187
+ result = self.runner(
188
+ ["sacct", "-j", job_id, "--parsable2", "--noheader", "-X", "-o", "State"],
189
+ self.command_timeout_s,
190
+ )
191
+ except (OSError, subprocess.TimeoutExpired) as exc:
192
+ raise SlurmQueryError(f"sacct did not run: {exc}") from exc
193
+ if result.returncode != 0:
194
+ raise SlurmQueryError(f"sacct failed ({result.returncode}): {result.stderr.strip()}")
195
+ state = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else ""
196
+ return state if state else GONE
197
+
198
+ def elapsed_seconds(self, job_id: str) -> int | None:
199
+ """How long the job actually ran (sacct Elapsed), or None when sacct
200
+ has no record. Raises SlurmQueryError when the query itself fails."""
201
+ if not job_id.isdigit():
202
+ raise ValueError(f"not a job id: {job_id!r}")
203
+ try:
204
+ result = self.runner(
205
+ ["sacct", "-j", job_id, "--parsable2", "--noheader", "-X", "-o", "Elapsed"],
206
+ self.command_timeout_s,
207
+ )
208
+ except (OSError, subprocess.TimeoutExpired) as exc:
209
+ raise SlurmQueryError(f"sacct did not run: {exc}") from exc
210
+ if result.returncode != 0:
211
+ raise SlurmQueryError(f"sacct failed ({result.returncode}): {result.stderr.strip()}")
212
+ text = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else ""
213
+ return parse_elapsed(text) if text else None
214
+
215
+ def pending_reason(self, job_id: str) -> str:
216
+ """Why a PENDING job is pending — Slurm's reason (`Dependency`,
217
+ `DependencyNeverSatisfied`, `Priority`, ...), or "" when squeue no
218
+ longer lists it. Raises SlurmQueryError when the query itself fails."""
219
+ if not job_id.isdigit():
220
+ raise ValueError(f"not a job id: {job_id!r}")
221
+ try:
222
+ result = self.runner(["squeue", "-j", job_id, "-h", "-o", "%r"], self.command_timeout_s)
223
+ except (OSError, subprocess.TimeoutExpired) as exc:
224
+ raise SlurmQueryError(f"squeue did not run: {exc}") from exc
225
+ if result.returncode != 0:
226
+ raise SlurmQueryError(f"squeue failed ({result.returncode}): {result.stderr.strip()}")
227
+ return result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else ""
228
+
229
+ def job_partition(self, job_id: str) -> str:
230
+ """The partition(s) a queued job currently sits in, as squeue prints
231
+ them, or "" when squeue no longer lists it. A site can MOVE a pending
232
+ job off the partition it was submitted to (Torch does, under
233
+ congestion); callers compare this with what they asked for."""
234
+ if not job_id.isdigit():
235
+ raise ValueError(f"not a job id: {job_id!r}")
236
+ try:
237
+ result = self.runner(["squeue", "-j", job_id, "-h", "-o", "%P"], self.command_timeout_s)
238
+ except (OSError, subprocess.TimeoutExpired) as exc:
239
+ raise SlurmQueryError(f"squeue did not run: {exc}") from exc
240
+ if result.returncode != 0:
241
+ raise SlurmQueryError(f"squeue failed ({result.returncode}): {result.stderr.strip()}")
242
+ return result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else ""
243
+
244
+ def active_job_names(self) -> list[str]:
245
+ """The names of this user's PENDING and RUNNING jobs. Names, not
246
+ commands: squeue's Command field is not guaranteed to carry --wrap
247
+ strings, while %j is always the submitted name. Raises
248
+ SlurmQueryError on failure — callers that delete things keyed on
249
+ this must treat blindness as "delete nothing"."""
250
+ try:
251
+ result = self.runner(
252
+ ["squeue", "--me", "--noheader", "-o", "%j"], self.command_timeout_s
253
+ )
254
+ except (OSError, subprocess.TimeoutExpired) as exc:
255
+ raise SlurmQueryError(f"squeue did not run: {exc}") from exc
256
+ if result.returncode != 0:
257
+ raise SlurmQueryError(f"squeue failed ({result.returncode}): {result.stderr.strip()}")
258
+ return [line.strip() for line in result.stdout.splitlines() if line.strip()]
259
+
260
+ def job_id_for_name(self, name: str) -> str:
261
+ """The id of this user's PENDING/RUNNING job with exactly `name`, or
262
+ "" if none. Authoritative for "is this still live" independent of any
263
+ local bookkeeping — a dispatched job is visible here even if the
264
+ submitter died before recording its id. Raises SlurmQueryError on a
265
+ failed query (the caller must not treat blindness as 'not running')."""
266
+ try:
267
+ result = self.runner(
268
+ ["squeue", "--me", "--name", name, "--noheader", "-o", "%i"],
269
+ self.command_timeout_s,
270
+ )
271
+ except (OSError, subprocess.TimeoutExpired) as exc:
272
+ raise SlurmQueryError(f"squeue did not run: {exc}") from exc
273
+ if result.returncode != 0:
274
+ raise SlurmQueryError(f"squeue failed ({result.returncode}): {result.stderr.strip()}")
275
+ ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
276
+ return ids[0] if ids else ""
277
+
278
+ def cancel(self, job_id: str) -> None:
279
+ """Cancel; idempotent (cancelling a finished job is not an error)."""
280
+ if not job_id.isdigit():
281
+ raise ValueError(f"not a job id: {job_id!r}")
282
+ result = self.runner(["scancel", job_id], self.command_timeout_s)
283
+ if result.returncode != 0:
284
+ log.warning("scancel %s: %s", job_id, result.stderr.strip())
285
+
286
+
287
+ # Local job ids start far above any real Slurm id so the two can never be
288
+ # confused in a record; they stay numeric because callers validate isdigit.
289
+ _LOCAL_JOB_BASE = 9_000_000_000
290
+
291
+
292
+ def _local_state_dir() -> Path | None:
293
+ """Where local job states persist across processes (the tick and the
294
+ attempts it spawns each hold their own LocalCompute): under the state
295
+ root when the deployment names one, else nowhere (memory-only — tests).
296
+ Local jobs are synchronous, so only TERMINAL states ever need sharing."""
297
+ root = os.environ.get("AUTORESEARCH_ROOT", "").strip()
298
+ return Path(root) / "local_jobs" if root else None
299
+
300
+
301
+ @dataclass
302
+ class LocalCompute:
303
+ """The same verbs, run as subprocesses in THIS allocation — synchronously:
304
+ `submit` returns with the job already terminal, so a caller that checks
305
+ for the result after submitting finds it on disk and nothing ever parks.
306
+ This is the degenerate backend for evals cheap enough to ride the current
307
+ allocation, for deployments with no cluster at all, and for tests. It runs
308
+ the identical job scripts the cluster runs (fresh checkout of the sealed
309
+ sha, results to the job dir); only WHERE they run differs."""
310
+
311
+ _states: dict[str, str] = field(default_factory=dict)
312
+ _seq: int = 0
313
+ minute_s: int = 60 # a walltime minute; tests shrink it to exercise the kill
314
+
315
+ def submit(self, spec: JobSpec) -> str:
316
+ if bool(spec.command) == bool(spec.script):
317
+ # same contract SlurmCompute enforces via to_argv
318
+ raise ValueError("exactly one of command/script must be set")
319
+ argv = ["sh", spec.script, *spec.script_args] if spec.script else ["sh", "-c", spec.command]
320
+ self._seq += 1
321
+ # unique across processes: the tick and its attempts each count from 1.
322
+ # A million-wide slot per (pid mod 10k); exhausting it fails LOUD —
323
+ # a silent wraparound would let one process read another's terminal
324
+ # state under a reused id.
325
+ if self._seq >= 1_000_000:
326
+ raise SlurmError("local job id space exhausted for this process")
327
+ job_id = str(_LOCAL_JOB_BASE + (os.getpid() % 10_000) * 1_000_000 + self._seq)
328
+
329
+ # An explicit env allowlist:
330
+ # the submitting process holds live keys (and any inherited
331
+ # APPTAINERENV_* would cross --cleanenv into the container), so the
332
+ # job script starts from a minimal environment and sets its own.
333
+ # AUTORESEARCH_* / REVIEW_HERMES_* pass through as a PREFIX rule:
334
+ # Slurm jobs inherit the tick's whole environment, and local jobs
335
+ # need the same config surface (compute mode, author backend, panel,
336
+ # key-file PATHS). Enumerating allowed names is how a mode flag dies
337
+ # silently (terra #222/#223) — but VALUE-bearing secret names under
338
+ # the prefix (a *_PAT / *_TOKEN / *_KEY, as opposed to a *_KEY_FILE
339
+ # path) must never reach a job that runs untrusted evaluation code.
340
+ def _secret_name(name: str) -> bool:
341
+ return name.endswith(("_PAT", "_TOKEN", "_SECRET", "_PASSWORD", "_KEY"))
342
+
343
+ job_env = {
344
+ k: v
345
+ for k, v in os.environ.items()
346
+ if k in ("PATH", "HOME", "LANG", "TMPDIR", "SLURM_TMPDIR", "USER", "LOGNAME")
347
+ or (k.startswith(("AUTORESEARCH_", "REVIEW_HERMES_")) and not _secret_name(k))
348
+ }
349
+ try:
350
+ # the job runs in its OWN session (= process group), so the
351
+ # walltime kill takes the whole tree — a job script waiting on
352
+ # children must not leave them running past the walltime, exactly
353
+ # as Slurm kills the job's group
354
+ proc = subprocess.Popen(
355
+ argv,
356
+ stdout=subprocess.PIPE,
357
+ stderr=subprocess.STDOUT,
358
+ text=True,
359
+ start_new_session=True,
360
+ env=job_env,
361
+ )
362
+ except OSError as exc:
363
+ raise SlurmError(f"local job {spec.job_name} failed to start: {exc}") from exc
364
+ try:
365
+ output, _ = proc.communicate(timeout=spec.time_minutes * self.minute_s)
366
+ state = "COMPLETED" if proc.returncode == 0 else "FAILED"
367
+ except subprocess.TimeoutExpired:
368
+ with contextlib.suppress(ProcessLookupError):
369
+ os.killpg(proc.pid, signal.SIGKILL) # pgid == pid (new session)
370
+ try:
371
+ # bounded drain: a child that re-setsid'd ESCAPED the group
372
+ # kill and still holds the pipe — it must not hang the
373
+ # submitter past the walltime. (A cgroup-less backend cannot
374
+ # reach a double-setsid escapee; Slurm's cgroup containment
375
+ # is the real jail — accepted local residual, logged.)
376
+ output, _ = proc.communicate(timeout=10)
377
+ except subprocess.TimeoutExpired:
378
+ if proc.stdout is not None:
379
+ proc.stdout.close()
380
+ output = ""
381
+ log.warning(
382
+ "local job %s: an escaped child survived the walltime kill", spec.job_name
383
+ )
384
+ state = "TIMEOUT"
385
+ state_dir = _local_state_dir()
386
+ if state_dir is not None:
387
+ try:
388
+ state_dir.mkdir(parents=True, exist_ok=True)
389
+ tmp = state_dir / f".{job_id}.{os.getpid()}.tmp"
390
+ tmp.write_text(state)
391
+ os.replace(tmp, state_dir / job_id)
392
+ # opportunistic prune: one entry per job would leak forever
393
+ # on a long-running loop; anything the sweep could still want
394
+ # is far younger than a day
395
+ cutoff = time.time() - 24 * 3600
396
+ for old in state_dir.iterdir():
397
+ try:
398
+ if old.stat().st_mtime < cutoff:
399
+ old.unlink()
400
+ except OSError:
401
+ pass
402
+ except OSError as exc:
403
+ log.warning("local job %s: state persist failed: %s", spec.job_name, exc)
404
+ if spec.output and spec.output != "/dev/null":
405
+ try:
406
+ with open(spec.output, "w") as fh:
407
+ fh.write(output)
408
+ except OSError as exc:
409
+ log.warning("local job %s: output write failed: %s", spec.job_name, exc)
410
+ self._states[job_id] = state
411
+ log.info("ran %s locally as job %s: %s", spec.job_name, job_id, state)
412
+ return job_id
413
+
414
+ def status(self, job_id: str) -> str:
415
+ if not job_id.isdigit():
416
+ raise ValueError(f"not a job id: {job_id!r}")
417
+ state = self._states.get(job_id, "")
418
+ if state:
419
+ return state
420
+ # another process's job (an attempt's launch, polled by the tick):
421
+ # synchronous jobs are terminal, so the persisted state is the truth
422
+ state_dir = _local_state_dir()
423
+ if state_dir is not None:
424
+ try:
425
+ return (state_dir / job_id).read_text().strip() or GONE
426
+ except OSError:
427
+ pass
428
+ return GONE
429
+
430
+ def pending_reason(self, job_id: str) -> str:
431
+ return "" # synchronous jobs are terminal at submit — never pending
432
+
433
+ def job_partition(self, job_id: str) -> str:
434
+ return "" # no scheduler, no partitions
435
+
436
+ def active_job_names(self) -> list[str]:
437
+ return [] # synchronous: nothing is ever pending or running
438
+
439
+ def job_id_for_name(self, name: str) -> str:
440
+ return ""
441
+
442
+ def cancel(self, job_id: str) -> None:
443
+ if not job_id.isdigit():
444
+ raise ValueError(f"not a job id: {job_id!r}")
445
+ # already terminal; cancelling a finished job is not an error
446
+
447
+
448
+ def parse_elapsed(text: str) -> int | None:
449
+ """Seconds in a sacct Elapsed field: `MM:SS`, `HH:MM:SS` or `D-HH:MM:SS`.
450
+ None for anything else (an unknown field never becomes a refund)."""
451
+ days = 0
452
+ if "-" in text:
453
+ day_part, _, text = text.partition("-")
454
+ if not day_part.isdigit():
455
+ return None
456
+ days = int(day_part)
457
+ parts = text.split(":")
458
+ if not parts or not all(p.isdigit() for p in parts) or len(parts) > 3:
459
+ return None
460
+ nums = [int(p) for p in parts]
461
+ while len(nums) < 3:
462
+ nums.insert(0, 0)
463
+ hours, minutes, seconds = nums
464
+ return days * 86400 + hours * 3600 + minutes * 60 + seconds
465
+
466
+
467
+ def is_terminal(state: str) -> bool:
468
+ """Whether a state string from `status` means the job is over.
469
+
470
+ GONE is deliberately NOT terminal here: it means "no record", and the
471
+ deadline-floor logic decides what that implies — not this predicate.
472
+ """
473
+ return any(state.startswith(t) for t in TERMINAL_STATES)
474
+
475
+
476
+ def is_pending(state: str) -> bool:
477
+ return state.startswith("PENDING")
478
+
479
+
480
+ def quote_command(parts: Sequence[str]) -> str:
481
+ """Shell-quote a command for JobSpec.command (--wrap takes a string)."""
482
+ return " ".join(shlex.quote(p) for p in parts)