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.
- outerloop/__init__.py +18 -0
- outerloop/__main__.py +3 -0
- outerloop/appauth.py +213 -0
- outerloop/appmanifest.py +198 -0
- outerloop/attempt.py +3481 -0
- outerloop/brief.py +515 -0
- outerloop/cli.py +439 -0
- outerloop/climbboard.py +1145 -0
- outerloop/compute.py +482 -0
- outerloop/contract.py +483 -0
- outerloop/contract_cli.py +63 -0
- outerloop/disk.py +164 -0
- outerloop/dispatch.py +586 -0
- outerloop/followup.py +2143 -0
- outerloop/github.py +1486 -0
- outerloop/harness.py +1449 -0
- outerloop/housekeeping.py +167 -0
- outerloop/init.py +313 -0
- outerloop/intake.py +129 -0
- outerloop/limits.py +80 -0
- outerloop/markers.py +48 -0
- outerloop/measure.py +523 -0
- outerloop/orchestrator.py +1901 -0
- outerloop/panel.py +188 -0
- outerloop/paths.py +27 -0
- outerloop/posting.py +160 -0
- outerloop/progress.py +170 -0
- outerloop/py.typed +0 -0
- outerloop/review.py +611 -0
- outerloop/review_agent.py +263 -0
- outerloop/review_agent_cli.py +209 -0
- outerloop/review_post_cli.py +162 -0
- outerloop/review_summarize_cli.py +163 -0
- outerloop/role_runner.py +229 -0
- outerloop/roles.py +247 -0
- outerloop/rolespec.py +89 -0
- outerloop/runstate.py +385 -0
- outerloop/steward.py +852 -0
- outerloop/style.py +12 -0
- outerloop/syscall.py +977 -0
- outerloop/syscall_cli.py +531 -0
- outerloop/tick.py +3166 -0
- outerloop/verifier.py +403 -0
- outerloop/verify_agent.py +149 -0
- outerloop/verify_agent_cli.py +95 -0
- outerloop/verify_post_cli.py +116 -0
- outerloop_science-0.1.0.dev0.dist-info/METADATA +145 -0
- outerloop_science-0.1.0.dev0.dist-info/RECORD +52 -0
- outerloop_science-0.1.0.dev0.dist-info/WHEEL +4 -0
- outerloop_science-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- outerloop_science-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
- 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)
|