lightcone-cli 0.2.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.
- lightcone/cli/__init__.py +16 -0
- lightcone/cli/claude/lightcone/agents/lc-extractor.md +114 -0
- lightcone/cli/claude/lightcone/guides/astra-reference.md +290 -0
- lightcone/cli/claude/lightcone/guides/lightcone-cli-reference.md +75 -0
- lightcone/cli/claude/lightcone/guides/ui-brand.md +86 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_git_commit_hook.py +303 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_hook.py +894 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_prepare_commit_msg.py +142 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_session_init_hook.py +83 -0
- lightcone/cli/claude/lightcone/hooks/langfuse_utils.py +457 -0
- lightcone/cli/claude/lightcone/scripts/activate-venv.sh +44 -0
- lightcone/cli/claude/lightcone/scripts/check-lc-run.sh +140 -0
- lightcone/cli/claude/lightcone/scripts/session-start.sh +140 -0
- lightcone/cli/claude/lightcone/scripts/validate-on-save.sh +77 -0
- lightcone/cli/claude/lightcone/skills/lc-build/SKILL.md +92 -0
- lightcone/cli/claude/lightcone/skills/lc-build/assets/loop-prompt.md +92 -0
- lightcone/cli/claude/lightcone/skills/lc-build/scripts/setup-lc-build.sh +240 -0
- lightcone/cli/claude/lightcone/skills/lc-feedback/SKILL.md +94 -0
- lightcone/cli/claude/lightcone/skills/lc-migrate/SKILL.md +98 -0
- lightcone/cli/claude/lightcone/skills/lc-new/SKILL.md +183 -0
- lightcone/cli/claude/lightcone/skills/lc-verify/SKILL.md +53 -0
- lightcone/cli/claude/lightcone/templates/CLAUDE.md +32 -0
- lightcone/cli/commands.py +2327 -0
- lightcone/cli/plugin.py +34 -0
- lightcone/engine/__init__.py +42 -0
- lightcone/engine/assets.py +418 -0
- lightcone/engine/container.py +370 -0
- lightcone/engine/io_manager.py +27 -0
- lightcone/engine/runner.py +1017 -0
- lightcone/engine/site_registry.py +142 -0
- lightcone/engine/status.py +135 -0
- lightcone/engine/targets.py +68 -0
- lightcone/engine/tree.py +245 -0
- lightcone/eval/__init__.py +25 -0
- lightcone/eval/build.py +148 -0
- lightcone/eval/cli.py +176 -0
- lightcone/eval/graders.py +192 -0
- lightcone/eval/harness.py +265 -0
- lightcone/eval/models.py +117 -0
- lightcone/eval/report.py +214 -0
- lightcone/eval/sandbox.py +394 -0
- lightcone_cli-0.2.0.dist-info/METADATA +16 -0
- lightcone_cli-0.2.0.dist-info/RECORD +46 -0
- lightcone_cli-0.2.0.dist-info/WHEEL +4 -0
- lightcone_cli-0.2.0.dist-info/entry_points.txt +2 -0
- lightcone_cli-0.2.0.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,1017 @@
|
|
|
1
|
+
"""ASTRA Container Runner — executes recipes in Docker/Podman, locally, or via SLURM."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Maximum number of characters to keep from stdout/stderr for metadata.
|
|
18
|
+
_TAIL_CHARS = 2000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run_streaming(
|
|
22
|
+
cmd: list[str] | str,
|
|
23
|
+
*,
|
|
24
|
+
shell: bool = False,
|
|
25
|
+
cwd: str | None = None,
|
|
26
|
+
env: dict[str, str] | None = None,
|
|
27
|
+
) -> tuple[int, str, str]:
|
|
28
|
+
"""Run a command, streaming stdout/stderr to the terminal in real time.
|
|
29
|
+
|
|
30
|
+
Returns ``(returncode, stdout_tail, stderr_tail)`` where each tail
|
|
31
|
+
contains at most the last ``_TAIL_CHARS`` characters of output.
|
|
32
|
+
"""
|
|
33
|
+
import selectors
|
|
34
|
+
|
|
35
|
+
stream_env = dict(env) if env else dict(os.environ)
|
|
36
|
+
stream_env["PYTHONUNBUFFERED"] = "1"
|
|
37
|
+
|
|
38
|
+
proc = subprocess.Popen(
|
|
39
|
+
cmd,
|
|
40
|
+
stdout=subprocess.PIPE,
|
|
41
|
+
stderr=subprocess.PIPE,
|
|
42
|
+
text=True,
|
|
43
|
+
shell=shell,
|
|
44
|
+
cwd=cwd,
|
|
45
|
+
env=stream_env,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
sel = selectors.DefaultSelector()
|
|
49
|
+
sel.register(proc.stdout, selectors.EVENT_READ)
|
|
50
|
+
sel.register(proc.stderr, selectors.EVENT_READ)
|
|
51
|
+
|
|
52
|
+
stdout_tail: list[str] = []
|
|
53
|
+
stderr_tail: list[str] = []
|
|
54
|
+
stdout_len = 0
|
|
55
|
+
stderr_len = 0
|
|
56
|
+
|
|
57
|
+
open_streams = 2
|
|
58
|
+
while open_streams > 0:
|
|
59
|
+
for key, _ in sel.select():
|
|
60
|
+
line = key.fileobj.readline()
|
|
61
|
+
if not line:
|
|
62
|
+
sel.unregister(key.fileobj)
|
|
63
|
+
open_streams -= 1
|
|
64
|
+
continue
|
|
65
|
+
if key.fileobj is proc.stdout:
|
|
66
|
+
sys.stdout.write(line)
|
|
67
|
+
sys.stdout.flush()
|
|
68
|
+
stdout_tail.append(line)
|
|
69
|
+
stdout_len += len(line)
|
|
70
|
+
while stdout_len > _TAIL_CHARS and len(stdout_tail) > 1:
|
|
71
|
+
stdout_len -= len(stdout_tail.pop(0))
|
|
72
|
+
else:
|
|
73
|
+
sys.stderr.write(line)
|
|
74
|
+
sys.stderr.flush()
|
|
75
|
+
stderr_tail.append(line)
|
|
76
|
+
stderr_len += len(line)
|
|
77
|
+
while stderr_len > _TAIL_CHARS and len(stderr_tail) > 1:
|
|
78
|
+
stderr_len -= len(stderr_tail.pop(0))
|
|
79
|
+
|
|
80
|
+
proc.wait()
|
|
81
|
+
sel.close()
|
|
82
|
+
return proc.returncode, "".join(stdout_tail), "".join(stderr_tail)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _find_venv(cwd: str | None, project_root: Path) -> Path | None:
|
|
87
|
+
"""Find .venv by checking cwd first, then walking up to project_root."""
|
|
88
|
+
if cwd:
|
|
89
|
+
cwd_path = Path(cwd)
|
|
90
|
+
venv = cwd_path / ".venv"
|
|
91
|
+
if (venv / "bin" / "python").exists():
|
|
92
|
+
return venv
|
|
93
|
+
# Walk up to project_root
|
|
94
|
+
current = cwd_path.parent
|
|
95
|
+
root_resolved = project_root.resolve()
|
|
96
|
+
while current >= root_resolved:
|
|
97
|
+
venv = current / ".venv"
|
|
98
|
+
if (venv / "bin" / "python").exists():
|
|
99
|
+
return venv
|
|
100
|
+
if current == root_resolved:
|
|
101
|
+
break
|
|
102
|
+
current = current.parent
|
|
103
|
+
|
|
104
|
+
# Fall back to project root
|
|
105
|
+
venv = project_root / ".venv"
|
|
106
|
+
if (venv / "bin" / "python").exists():
|
|
107
|
+
return venv
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _substitute_python(command: str, python_path: str) -> str:
|
|
112
|
+
"""Replace a leading ``python `` with a specific interpreter path."""
|
|
113
|
+
if command.startswith("python "):
|
|
114
|
+
return python_path + command[len("python"):]
|
|
115
|
+
return command
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class ExecutionResult:
|
|
120
|
+
"""Result of executing a recipe."""
|
|
121
|
+
exit_code: int
|
|
122
|
+
output_path: Path
|
|
123
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _build_cli_args(params: dict[str, Any], universe_id: str) -> list[str]:
|
|
127
|
+
"""Build CLI arguments from universe decisions."""
|
|
128
|
+
args = ["--universe", universe_id]
|
|
129
|
+
for key, value in params.items():
|
|
130
|
+
args.extend([f"--{key}", str(value)])
|
|
131
|
+
return args
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def translate_resources_to_docker_flags(resources: dict[str, Any]) -> list[str]:
|
|
135
|
+
"""Translate ASTRA resource requirements to Docker CLI flags."""
|
|
136
|
+
flags: list[str] = []
|
|
137
|
+
if cpus := resources.get("cpus"):
|
|
138
|
+
flags.append(f"--cpus={cpus}")
|
|
139
|
+
if memory := resources.get("memory"):
|
|
140
|
+
flags.append(f"--memory={memory.lower()}")
|
|
141
|
+
if gpus := resources.get("gpus"):
|
|
142
|
+
flags.append(f"--gpus={gpus}")
|
|
143
|
+
return flags
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ASTRAContainerRunner:
|
|
147
|
+
"""Executes ASTRA recipes via Docker, local subprocess, or SLURM.
|
|
148
|
+
|
|
149
|
+
When backend is "docker", attempts Docker execution first. If Docker
|
|
150
|
+
fails (missing image, daemon not running, non-zero exit), falls back to
|
|
151
|
+
local subprocess execution with a warning.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
project_root: str,
|
|
157
|
+
backend: str = "docker",
|
|
158
|
+
default_container: str | None = None,
|
|
159
|
+
target_config: dict[str, Any] | None = None,
|
|
160
|
+
container_runtime: str | None = None,
|
|
161
|
+
):
|
|
162
|
+
"""Initialise an ASTRA container runner.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
project_root: Absolute path to the ASTRA project directory.
|
|
166
|
+
All recipe commands are executed with this as their working
|
|
167
|
+
directory unless *cwd_override* is supplied at call time.
|
|
168
|
+
backend: Execution backend to use. One of ``"docker"``,
|
|
169
|
+
``"local"``, ``"venv"``, or ``"slurm"``. The ``"docker"``
|
|
170
|
+
backend automatically falls back to ``"venv"`` (or
|
|
171
|
+
``"local"``) when the container run fails.
|
|
172
|
+
default_container: Analysis-level container image resolved from
|
|
173
|
+
``astra.yaml``. Per-recipe containers override this value.
|
|
174
|
+
target_config: Parsed SLURM target configuration dict (from
|
|
175
|
+
``~/.lightcone/targets/<name>.yaml``). Used only when *backend*
|
|
176
|
+
is ``"slurm"``.
|
|
177
|
+
container_runtime: Local container runtime binary name
|
|
178
|
+
(``"docker"`` or ``"podman"``). When ``None``, the runner
|
|
179
|
+
uses whatever is available on ``PATH``.
|
|
180
|
+
"""
|
|
181
|
+
self.project_root = Path(project_root)
|
|
182
|
+
self.backend = backend
|
|
183
|
+
self.default_container = default_container
|
|
184
|
+
self.target_config = target_config or {}
|
|
185
|
+
self.container_runtime = container_runtime
|
|
186
|
+
self._venv_deps_checked = False
|
|
187
|
+
|
|
188
|
+
def execute(
|
|
189
|
+
self,
|
|
190
|
+
command: str,
|
|
191
|
+
output_id: str,
|
|
192
|
+
universe_id: str,
|
|
193
|
+
container: str | None = None,
|
|
194
|
+
inputs: list[str] | None = None,
|
|
195
|
+
resources: dict[str, Any] | None = None,
|
|
196
|
+
params: dict[str, Any] | None = None,
|
|
197
|
+
external_inputs: dict[str, str] | None = None,
|
|
198
|
+
cwd_override: str | None = None,
|
|
199
|
+
) -> ExecutionResult:
|
|
200
|
+
"""Execute a recipe, dispatching to the configured backend.
|
|
201
|
+
|
|
202
|
+
For the "docker" backend, falls back to local execution when Docker
|
|
203
|
+
is unavailable or the container run fails.
|
|
204
|
+
"""
|
|
205
|
+
cli_args = _build_cli_args(params or {}, universe_id)
|
|
206
|
+
full_command = (command + " " + " ".join(cli_args)).strip()
|
|
207
|
+
results_dir = self.project_root / "results" / universe_id
|
|
208
|
+
results_dir.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
|
|
210
|
+
# Effective working directory: cwd_override (for sub-analysis recipes)
|
|
211
|
+
# or project_root
|
|
212
|
+
effective_cwd = cwd_override or str(self.project_root)
|
|
213
|
+
|
|
214
|
+
if self.backend == "local":
|
|
215
|
+
return self._run_local(
|
|
216
|
+
full_command, output_id, universe_id, cwd=effective_cwd,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if self.backend == "slurm":
|
|
220
|
+
return self._run_slurm(
|
|
221
|
+
command=full_command,
|
|
222
|
+
container=container or self.default_container,
|
|
223
|
+
input_ids=inputs or [],
|
|
224
|
+
output_id=output_id,
|
|
225
|
+
universe_id=universe_id,
|
|
226
|
+
resources=resources or {},
|
|
227
|
+
external_inputs=external_inputs,
|
|
228
|
+
cwd=effective_cwd,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if self.backend == "venv":
|
|
232
|
+
return self._run_venv(
|
|
233
|
+
full_command, output_id, universe_id, cwd=effective_cwd,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Container backend — try container runtime, fall back to venv or local.
|
|
237
|
+
# Note: the explicit "local" backend (set via target config) skips dep
|
|
238
|
+
# installation and runs in the current Python env. The implicit fallback
|
|
239
|
+
# here goes to _run_venv (with dep installation) when .venv is present,
|
|
240
|
+
# and only falls back to _run_local when .venv is absent.
|
|
241
|
+
effective_container = container or self.default_container
|
|
242
|
+
if effective_container:
|
|
243
|
+
result = self._run_container(
|
|
244
|
+
command=full_command,
|
|
245
|
+
container=effective_container,
|
|
246
|
+
universe_id=universe_id,
|
|
247
|
+
resources=resources or {},
|
|
248
|
+
runtime=self.container_runtime or "docker",
|
|
249
|
+
)
|
|
250
|
+
if result.exit_code == 0:
|
|
251
|
+
return result
|
|
252
|
+
# Container failed — fall back to venv (or local if venv is absent)
|
|
253
|
+
logger.warning(
|
|
254
|
+
"%s execution failed for '%s' (exit code %d). "
|
|
255
|
+
"Falling back to venv execution.\n stderr: %s",
|
|
256
|
+
self.container_runtime or "docker", output_id, result.exit_code,
|
|
257
|
+
result.metadata.get("stderr", "")[:200],
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
venv_python = self.project_root / ".venv" / "bin" / "python"
|
|
261
|
+
if venv_python.exists():
|
|
262
|
+
return self._run_venv(
|
|
263
|
+
command=full_command,
|
|
264
|
+
output_id=output_id,
|
|
265
|
+
universe_id=universe_id,
|
|
266
|
+
warn=effective_container is not None,
|
|
267
|
+
cwd=effective_cwd,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# No .venv available — fall back to the current Python environment so
|
|
271
|
+
# that projects without a venv (e.g. pre-existing installs that predate
|
|
272
|
+
# lc init) continue to work rather than surfacing a confusing error.
|
|
273
|
+
logger.warning(
|
|
274
|
+
"No .venv found for '%s'; executing locally without dep isolation. "
|
|
275
|
+
"Run 'lc init' to create a project venv with dependencies installed.",
|
|
276
|
+
output_id,
|
|
277
|
+
)
|
|
278
|
+
return self._run_local(
|
|
279
|
+
command=full_command,
|
|
280
|
+
output_id=output_id,
|
|
281
|
+
universe_id=universe_id,
|
|
282
|
+
warn=effective_container is not None,
|
|
283
|
+
cwd=effective_cwd,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def _run_container(
|
|
287
|
+
self,
|
|
288
|
+
command: str,
|
|
289
|
+
container: str,
|
|
290
|
+
universe_id: str,
|
|
291
|
+
resources: dict[str, Any],
|
|
292
|
+
runtime: str = "docker",
|
|
293
|
+
) -> ExecutionResult:
|
|
294
|
+
"""Execute a recipe in a container (Docker or Podman).
|
|
295
|
+
|
|
296
|
+
Mounts the project root at /workspace so scripts can read data and
|
|
297
|
+
write results using their normal relative paths.
|
|
298
|
+
"""
|
|
299
|
+
cmd = [runtime, "run", "--rm"]
|
|
300
|
+
cmd.extend(translate_resources_to_docker_flags(resources))
|
|
301
|
+
cmd.extend([
|
|
302
|
+
"-v", f"{self.project_root}:/workspace",
|
|
303
|
+
"-w", "/workspace",
|
|
304
|
+
container,
|
|
305
|
+
"sh", "-c", command,
|
|
306
|
+
])
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
returncode, stdout_tail, stderr_tail = _run_streaming(cmd)
|
|
310
|
+
except FileNotFoundError:
|
|
311
|
+
return ExecutionResult(
|
|
312
|
+
exit_code=127,
|
|
313
|
+
output_path=self.project_root / "results" / universe_id,
|
|
314
|
+
metadata={"stderr": f"{runtime}: command not found"},
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
return ExecutionResult(
|
|
318
|
+
exit_code=returncode,
|
|
319
|
+
output_path=self.project_root / "results" / universe_id,
|
|
320
|
+
metadata={
|
|
321
|
+
"stdout": stdout_tail,
|
|
322
|
+
"stderr": stderr_tail,
|
|
323
|
+
"backend": runtime,
|
|
324
|
+
"container_command": " ".join(cmd),
|
|
325
|
+
},
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def _run_local(
|
|
329
|
+
self,
|
|
330
|
+
command: str,
|
|
331
|
+
output_id: str,
|
|
332
|
+
universe_id: str,
|
|
333
|
+
warn: bool = False,
|
|
334
|
+
cwd: str | None = None,
|
|
335
|
+
) -> ExecutionResult:
|
|
336
|
+
"""Execute a recipe as a local subprocess.
|
|
337
|
+
|
|
338
|
+
Uses the current Python environment. Decision parameters are passed
|
|
339
|
+
as CLI arguments.
|
|
340
|
+
"""
|
|
341
|
+
if warn:
|
|
342
|
+
logger.warning(
|
|
343
|
+
"Executing '%s' locally (no container). "
|
|
344
|
+
"Results may differ from containerised execution.",
|
|
345
|
+
output_id,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
full_command = _substitute_python(command, sys.executable)
|
|
349
|
+
|
|
350
|
+
returncode, stdout_tail, stderr_tail = _run_streaming(
|
|
351
|
+
full_command, shell=True, cwd=cwd or str(self.project_root),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
output_path = self.project_root / "results" / universe_id
|
|
355
|
+
return ExecutionResult(
|
|
356
|
+
exit_code=returncode,
|
|
357
|
+
output_path=output_path,
|
|
358
|
+
metadata={
|
|
359
|
+
"stdout": stdout_tail,
|
|
360
|
+
"stderr": stderr_tail,
|
|
361
|
+
"backend": "local",
|
|
362
|
+
},
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def _run_venv(
|
|
366
|
+
self,
|
|
367
|
+
command: str,
|
|
368
|
+
output_id: str,
|
|
369
|
+
universe_id: str,
|
|
370
|
+
warn: bool = False,
|
|
371
|
+
cwd: str | None = None,
|
|
372
|
+
) -> ExecutionResult:
|
|
373
|
+
"""Execute a recipe in the project's virtual environment.
|
|
374
|
+
|
|
375
|
+
Uses the ``.venv/`` created by ``lc init``. Ensures that
|
|
376
|
+
dependencies from ``requirements*.txt`` are installed before
|
|
377
|
+
running, using a hash-based marker to skip redundant installs.
|
|
378
|
+
|
|
379
|
+
For sub-analysis recipes, the venv is resolved by walking up from
|
|
380
|
+
the working directory to the project root.
|
|
381
|
+
"""
|
|
382
|
+
if warn:
|
|
383
|
+
logger.warning(
|
|
384
|
+
"Executing '%s' in project venv. "
|
|
385
|
+
"Results may differ from containerised execution.",
|
|
386
|
+
output_id,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# Find venv: check cwd first, then walk up to project root
|
|
390
|
+
venv_path = _find_venv(cwd, self.project_root)
|
|
391
|
+
|
|
392
|
+
if venv_path is None:
|
|
393
|
+
return ExecutionResult(
|
|
394
|
+
exit_code=1,
|
|
395
|
+
output_path=self.project_root / "results" / universe_id,
|
|
396
|
+
metadata={
|
|
397
|
+
"stderr": (
|
|
398
|
+
"No .venv found in project root. "
|
|
399
|
+
"Run 'lc init' or create a virtual environment first."
|
|
400
|
+
),
|
|
401
|
+
"backend": "venv",
|
|
402
|
+
},
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
venv_python = venv_path / "bin" / "python"
|
|
406
|
+
|
|
407
|
+
# Ensure dependencies are installed
|
|
408
|
+
self._ensure_venv_deps(venv_path)
|
|
409
|
+
|
|
410
|
+
full_command = _substitute_python(command, str(venv_python))
|
|
411
|
+
|
|
412
|
+
env = {
|
|
413
|
+
**os.environ,
|
|
414
|
+
"VIRTUAL_ENV": str(venv_path),
|
|
415
|
+
"PATH": f"{venv_path / 'bin'}:{os.environ.get('PATH', '')}",
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
returncode, stdout_tail, stderr_tail = _run_streaming(
|
|
419
|
+
full_command, shell=True, cwd=cwd or str(self.project_root), env=env,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
output_path = self.project_root / "results" / universe_id
|
|
423
|
+
return ExecutionResult(
|
|
424
|
+
exit_code=returncode,
|
|
425
|
+
output_path=output_path,
|
|
426
|
+
metadata={
|
|
427
|
+
"stdout": stdout_tail,
|
|
428
|
+
"stderr": stderr_tail,
|
|
429
|
+
"backend": "venv",
|
|
430
|
+
"venv_path": str(venv_path),
|
|
431
|
+
},
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
def _ensure_venv_deps(self, venv_path: Path) -> None:
|
|
435
|
+
"""Install requirements into venv if they have changed.
|
|
436
|
+
|
|
437
|
+
Computes a hash of all ``requirements*.txt`` files and compares
|
|
438
|
+
it to a marker file (``.venv/.deps-hash``). If the hash matches,
|
|
439
|
+
installation is skipped. Once checked successfully within this
|
|
440
|
+
runner instance, subsequent calls are no-ops.
|
|
441
|
+
"""
|
|
442
|
+
if self._venv_deps_checked:
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
from lightcone.engine.container import find_dependency_files, hash_file_contents
|
|
446
|
+
|
|
447
|
+
dep_files = find_dependency_files(self.project_root)
|
|
448
|
+
req_files = [f for f in dep_files if f.name.startswith("requirements")]
|
|
449
|
+
if not req_files:
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
current_hash = hash_file_contents(req_files)
|
|
453
|
+
|
|
454
|
+
marker = venv_path / ".deps-hash"
|
|
455
|
+
if marker.exists() and marker.read_text().strip() == current_hash:
|
|
456
|
+
return
|
|
457
|
+
|
|
458
|
+
pip_path = venv_path / "bin" / "pip"
|
|
459
|
+
all_installed = True
|
|
460
|
+
for req_file in req_files:
|
|
461
|
+
logger.info("Installing dependencies from %s into .venv ...", req_file.name)
|
|
462
|
+
install_result = subprocess.run(
|
|
463
|
+
[str(pip_path), "install", "-r", str(req_file)],
|
|
464
|
+
capture_output=True,
|
|
465
|
+
text=True,
|
|
466
|
+
cwd=str(self.project_root),
|
|
467
|
+
)
|
|
468
|
+
if install_result.returncode != 0:
|
|
469
|
+
logger.warning(
|
|
470
|
+
"pip install -r %s failed: %s",
|
|
471
|
+
req_file.name, install_result.stderr[:200],
|
|
472
|
+
)
|
|
473
|
+
all_installed = False
|
|
474
|
+
|
|
475
|
+
# Only record the hash when all installs succeeded — a failed install
|
|
476
|
+
# that wrote the marker would be silently skipped on the next run.
|
|
477
|
+
if all_installed:
|
|
478
|
+
marker.write_text(current_hash + "\n")
|
|
479
|
+
self._venv_deps_checked = True
|
|
480
|
+
|
|
481
|
+
def _run_slurm_interactive(
|
|
482
|
+
self,
|
|
483
|
+
command: str,
|
|
484
|
+
container: str | None,
|
|
485
|
+
output_id: str,
|
|
486
|
+
universe_id: str,
|
|
487
|
+
resources: dict[str, Any],
|
|
488
|
+
external_inputs: dict[str, str] | None = None,
|
|
489
|
+
cwd: str | None = None,
|
|
490
|
+
) -> ExecutionResult:
|
|
491
|
+
"""Execute a recipe via srun inside an existing interactive allocation.
|
|
492
|
+
|
|
493
|
+
Runs synchronously — no job submission or polling needed.
|
|
494
|
+
"""
|
|
495
|
+
effective_cwd = cwd or str(self.project_root)
|
|
496
|
+
scheduler = self.target_config.get("scheduler", {})
|
|
497
|
+
container_runtime = scheduler.get("container_runtime", "podman-hpc")
|
|
498
|
+
|
|
499
|
+
output_path = Path(effective_cwd) / "results" / universe_id
|
|
500
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
501
|
+
|
|
502
|
+
# Build the execution command
|
|
503
|
+
if container and container_runtime == "podman-hpc":
|
|
504
|
+
exec_command = _podman_hpc_run_command(
|
|
505
|
+
command, container, self.project_root, resources, scheduler,
|
|
506
|
+
external_inputs=external_inputs,
|
|
507
|
+
)
|
|
508
|
+
else:
|
|
509
|
+
# No container — symlink external inputs into data/ directory
|
|
510
|
+
if external_inputs:
|
|
511
|
+
data_dir = Path(effective_cwd) / "data"
|
|
512
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
513
|
+
for input_id, source in sorted(external_inputs.items()):
|
|
514
|
+
link = data_dir / input_id
|
|
515
|
+
if link.is_symlink() or link.exists():
|
|
516
|
+
link.unlink()
|
|
517
|
+
link.symlink_to(source)
|
|
518
|
+
exec_command = command
|
|
519
|
+
|
|
520
|
+
cmd = ["srun", "bash", "-c", exec_command]
|
|
521
|
+
|
|
522
|
+
logger.info(
|
|
523
|
+
"Running %s/%s interactively (SLURM_JOB_ID=%s)",
|
|
524
|
+
output_id, universe_id, os.environ.get("SLURM_JOB_ID"),
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
try:
|
|
528
|
+
returncode, stdout_tail, stderr_tail = _run_streaming(
|
|
529
|
+
cmd, cwd=effective_cwd,
|
|
530
|
+
)
|
|
531
|
+
except FileNotFoundError:
|
|
532
|
+
return ExecutionResult(
|
|
533
|
+
exit_code=127,
|
|
534
|
+
output_path=output_path,
|
|
535
|
+
metadata={"stderr": "srun: command not found"},
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
return ExecutionResult(
|
|
539
|
+
exit_code=returncode,
|
|
540
|
+
output_path=output_path,
|
|
541
|
+
metadata={
|
|
542
|
+
"backend": "slurm-interactive",
|
|
543
|
+
"slurm_job_id": os.environ.get("SLURM_JOB_ID", ""),
|
|
544
|
+
"container_runtime": container_runtime if container else None,
|
|
545
|
+
"stdout": stdout_tail,
|
|
546
|
+
"stderr": stderr_tail,
|
|
547
|
+
},
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
def _run_slurm(
|
|
551
|
+
self,
|
|
552
|
+
command: str,
|
|
553
|
+
container: str | None,
|
|
554
|
+
input_ids: list[str],
|
|
555
|
+
output_id: str,
|
|
556
|
+
universe_id: str,
|
|
557
|
+
resources: dict[str, Any],
|
|
558
|
+
external_inputs: dict[str, str] | None = None,
|
|
559
|
+
cwd: str | None = None,
|
|
560
|
+
) -> ExecutionResult:
|
|
561
|
+
"""Execute a recipe via SLURM.
|
|
562
|
+
|
|
563
|
+
When ``SLURM_JOB_ID`` is set (i.e. we are inside an interactive
|
|
564
|
+
``salloc`` session), runs the command synchronously via ``srun``
|
|
565
|
+
for fast iteration. Otherwise, generates an sbatch script,
|
|
566
|
+
submits it, and polls for completion.
|
|
567
|
+
"""
|
|
568
|
+
# Fast path: interactive allocation detected
|
|
569
|
+
if os.environ.get("SLURM_JOB_ID"):
|
|
570
|
+
return self._run_slurm_interactive(
|
|
571
|
+
command=command,
|
|
572
|
+
container=container,
|
|
573
|
+
output_id=output_id,
|
|
574
|
+
universe_id=universe_id,
|
|
575
|
+
resources=resources,
|
|
576
|
+
external_inputs=external_inputs,
|
|
577
|
+
cwd=cwd,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
scheduler = self.target_config.get("scheduler", {})
|
|
581
|
+
container_runtime = scheduler.get("container_runtime", "podman-hpc")
|
|
582
|
+
|
|
583
|
+
output_path = self.project_root / "results" / universe_id
|
|
584
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
585
|
+
|
|
586
|
+
# Generate the sbatch script
|
|
587
|
+
resource_limits = self.target_config.get("resource_limits", {})
|
|
588
|
+
script = generate_sbatch_script(
|
|
589
|
+
command=command,
|
|
590
|
+
container=container,
|
|
591
|
+
container_runtime=container_runtime,
|
|
592
|
+
project_root=self.project_root,
|
|
593
|
+
output_id=output_id,
|
|
594
|
+
universe_id=universe_id,
|
|
595
|
+
resources=resources,
|
|
596
|
+
scheduler_config=scheduler,
|
|
597
|
+
resource_limits=resource_limits,
|
|
598
|
+
external_inputs=external_inputs,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
# Write script to a temp file inside the project so it's on the
|
|
602
|
+
# shared filesystem visible to compute nodes.
|
|
603
|
+
scripts_dir = self.project_root / "results" / ".slurm"
|
|
604
|
+
scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
605
|
+
job_name = f"{output_id}_{universe_id}"
|
|
606
|
+
script_path = scripts_dir / f"{job_name}.sh"
|
|
607
|
+
script_path.write_text(script)
|
|
608
|
+
script_path.chmod(0o755)
|
|
609
|
+
|
|
610
|
+
logger.info("Submitting SLURM job for %s/%s", output_id, universe_id)
|
|
611
|
+
logger.debug("sbatch script:\n%s", script)
|
|
612
|
+
|
|
613
|
+
# Submit via sbatch
|
|
614
|
+
try:
|
|
615
|
+
submit_result = subprocess.run(
|
|
616
|
+
["sbatch", str(script_path)],
|
|
617
|
+
capture_output=True,
|
|
618
|
+
text=True,
|
|
619
|
+
cwd=str(self.project_root),
|
|
620
|
+
)
|
|
621
|
+
except FileNotFoundError:
|
|
622
|
+
return ExecutionResult(
|
|
623
|
+
exit_code=127,
|
|
624
|
+
output_path=output_path,
|
|
625
|
+
metadata={"stderr": "sbatch: command not found"},
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
if submit_result.returncode != 0:
|
|
629
|
+
return ExecutionResult(
|
|
630
|
+
exit_code=submit_result.returncode,
|
|
631
|
+
output_path=output_path,
|
|
632
|
+
metadata={
|
|
633
|
+
"stderr": submit_result.stderr,
|
|
634
|
+
"backend": "slurm",
|
|
635
|
+
},
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
# Parse job ID from "Submitted batch job 12345"
|
|
639
|
+
job_id = _parse_sbatch_job_id(submit_result.stdout)
|
|
640
|
+
if job_id is None:
|
|
641
|
+
return ExecutionResult(
|
|
642
|
+
exit_code=1,
|
|
643
|
+
output_path=output_path,
|
|
644
|
+
metadata={
|
|
645
|
+
"stderr": f"Could not parse job ID from: {submit_result.stdout}",
|
|
646
|
+
"backend": "slurm",
|
|
647
|
+
},
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
logger.info("SLURM job submitted: %s", job_id)
|
|
651
|
+
|
|
652
|
+
# Poll for completion
|
|
653
|
+
poll_config = self.target_config.get("poll", {})
|
|
654
|
+
poll_interval = poll_config.get("interval_seconds", 15)
|
|
655
|
+
poll_timeout = poll_config.get("timeout_seconds", 14400) # 4h default
|
|
656
|
+
exit_code, job_metadata = _poll_slurm_job(
|
|
657
|
+
job_id, poll_interval=poll_interval, poll_timeout=poll_timeout,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
# Collect stdout/stderr from SLURM output files
|
|
661
|
+
slurm_stdout = ""
|
|
662
|
+
slurm_stderr = ""
|
|
663
|
+
stdout_file = scripts_dir / f"{job_name}.out"
|
|
664
|
+
stderr_file = scripts_dir / f"{job_name}.err"
|
|
665
|
+
if stdout_file.exists():
|
|
666
|
+
slurm_stdout = stdout_file.read_text()[-2000:]
|
|
667
|
+
if stderr_file.exists():
|
|
668
|
+
slurm_stderr = stderr_file.read_text()[-2000:]
|
|
669
|
+
|
|
670
|
+
return ExecutionResult(
|
|
671
|
+
exit_code=exit_code,
|
|
672
|
+
output_path=output_path,
|
|
673
|
+
metadata={
|
|
674
|
+
"backend": "slurm",
|
|
675
|
+
"slurm_job_id": job_id,
|
|
676
|
+
"container_runtime": container_runtime,
|
|
677
|
+
"stdout": slurm_stdout,
|
|
678
|
+
"stderr": slurm_stderr,
|
|
679
|
+
"sbatch_script": str(script_path),
|
|
680
|
+
**job_metadata,
|
|
681
|
+
},
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
# ---------------------------------------------------------------------------
|
|
686
|
+
# SLURM helpers
|
|
687
|
+
# ---------------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def translate_resources_to_slurm_directives(
|
|
691
|
+
resources: dict[str, Any],
|
|
692
|
+
scheduler_config: dict[str, Any] | None = None,
|
|
693
|
+
*,
|
|
694
|
+
resource_limits: dict[str, Any] | None = None,
|
|
695
|
+
) -> list[str]:
|
|
696
|
+
"""Translate ASTRA resource requirements to SLURM #SBATCH directives.
|
|
697
|
+
|
|
698
|
+
Returns a list of directive strings (without the ``#SBATCH`` prefix).
|
|
699
|
+
|
|
700
|
+
When *resource_limits* is provided (non-``None``) and no explicit
|
|
701
|
+
``time_limit`` appears in *resources*, a default ``--time`` directive is
|
|
702
|
+
emitted using ``resource_limits["max_walltime_minutes"]`` (falling back
|
|
703
|
+
to 30 minutes). This ensures SLURM jobs always have a walltime.
|
|
704
|
+
"""
|
|
705
|
+
scheduler_config = scheduler_config or {}
|
|
706
|
+
directives: list[str] = []
|
|
707
|
+
|
|
708
|
+
# Extra SLURM args from CLI passthrough (e.g. --partition, --qos, --constraint)
|
|
709
|
+
extra_args = scheduler_config.get("extra_slurm_args", [])
|
|
710
|
+
|
|
711
|
+
# Helper to check if a flag is already in extra args (CLI overrides target)
|
|
712
|
+
def _in_extra(flag: str) -> bool:
|
|
713
|
+
return any(a.startswith(flag) for a in extra_args)
|
|
714
|
+
|
|
715
|
+
# Extract constraint from extra args for account suffix resolution
|
|
716
|
+
constraint = scheduler_config.get("constraint")
|
|
717
|
+
for arg in extra_args:
|
|
718
|
+
if arg.startswith("--constraint"):
|
|
719
|
+
constraint = arg.split("=", 1)[1] if "=" in arg else None
|
|
720
|
+
|
|
721
|
+
account = scheduler_config.get("account")
|
|
722
|
+
# Apply site-specific account suffix (e.g. _g for GPU on Perlmutter)
|
|
723
|
+
if account and not _in_extra("--account"):
|
|
724
|
+
site_key = scheduler_config.get("site")
|
|
725
|
+
if site_key and constraint:
|
|
726
|
+
from lightcone.engine.site_registry import resolve_account
|
|
727
|
+
account = resolve_account(site_key, account, constraint)
|
|
728
|
+
directives.append(f"--account={account}")
|
|
729
|
+
if not _in_extra("--partition"):
|
|
730
|
+
if partition := scheduler_config.get("partition"):
|
|
731
|
+
directives.append(f"--partition={partition}")
|
|
732
|
+
if not _in_extra("--qos"):
|
|
733
|
+
if qos := scheduler_config.get("qos"):
|
|
734
|
+
directives.append(f"--qos={qos}")
|
|
735
|
+
if not _in_extra("--constraint"):
|
|
736
|
+
if constraint:
|
|
737
|
+
directives.append(f"--constraint={constraint}")
|
|
738
|
+
|
|
739
|
+
if nodes := resources.get("nodes"):
|
|
740
|
+
directives.append(f"--nodes={nodes}")
|
|
741
|
+
if cpus := resources.get("cpus"):
|
|
742
|
+
directives.append(f"--cpus-per-task={cpus}")
|
|
743
|
+
if memory := resources.get("memory"):
|
|
744
|
+
directives.append(f"--mem={memory}")
|
|
745
|
+
if not _in_extra("--gpus"):
|
|
746
|
+
if gpus := resources.get("gpus"):
|
|
747
|
+
directives.append(f"--gpus={gpus}")
|
|
748
|
+
if time_limit := resources.get("time_limit"):
|
|
749
|
+
directives.append(f"--time={_normalise_time_limit(time_limit)}")
|
|
750
|
+
elif resource_limits is not None:
|
|
751
|
+
# No explicit time_limit — apply a default so SLURM doesn't reject
|
|
752
|
+
# the job. Use the target's max_walltime_minutes, or 30 min.
|
|
753
|
+
default_minutes = resource_limits.get("max_walltime_minutes", 30)
|
|
754
|
+
logger.warning(
|
|
755
|
+
"No time_limit in recipe resources; defaulting to %d minutes "
|
|
756
|
+
"(from resource_limits.max_walltime_minutes)",
|
|
757
|
+
default_minutes,
|
|
758
|
+
)
|
|
759
|
+
directives.append(f"--time={_normalise_time_limit(default_minutes)}")
|
|
760
|
+
|
|
761
|
+
# Append any extra SLURM flags passed through from the CLI
|
|
762
|
+
directives.extend(extra_args)
|
|
763
|
+
|
|
764
|
+
return directives
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _normalise_time_limit(value: str | int) -> str:
|
|
768
|
+
"""Convert time_limit values like '2h', '30m', 120 to HH:MM:SS."""
|
|
769
|
+
if isinstance(value, int):
|
|
770
|
+
# Assume minutes
|
|
771
|
+
hours, minutes = divmod(value, 60)
|
|
772
|
+
return f"{hours:02d}:{minutes:02d}:00"
|
|
773
|
+
value = str(value).strip()
|
|
774
|
+
match = re.match(r"^(\d+)([hm]?)$", value, re.IGNORECASE)
|
|
775
|
+
if match:
|
|
776
|
+
num, unit = int(match.group(1)), match.group(2).lower()
|
|
777
|
+
if unit == "h":
|
|
778
|
+
return f"{num:02d}:00:00"
|
|
779
|
+
elif unit == "m":
|
|
780
|
+
hours, minutes = divmod(num, 60)
|
|
781
|
+
return f"{hours:02d}:{minutes:02d}:00"
|
|
782
|
+
else:
|
|
783
|
+
# bare number = minutes
|
|
784
|
+
hours, minutes = divmod(num, 60)
|
|
785
|
+
return f"{hours:02d}:{minutes:02d}:00"
|
|
786
|
+
# Already in HH:MM:SS or similar — pass through
|
|
787
|
+
return value
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def generate_sbatch_script(
|
|
791
|
+
command: str,
|
|
792
|
+
container: str | None,
|
|
793
|
+
container_runtime: str,
|
|
794
|
+
project_root: Path,
|
|
795
|
+
output_id: str,
|
|
796
|
+
universe_id: str,
|
|
797
|
+
resources: dict[str, Any],
|
|
798
|
+
scheduler_config: dict[str, Any] | None = None,
|
|
799
|
+
resource_limits: dict[str, Any] | None = None,
|
|
800
|
+
external_inputs: dict[str, str] | None = None,
|
|
801
|
+
) -> str:
|
|
802
|
+
"""Generate an sbatch script for a recipe execution.
|
|
803
|
+
|
|
804
|
+
Uses ``podman-hpc`` as the container runtime on Perlmutter. Containers are
|
|
805
|
+
run via ``podman-hpc run`` with optional ``--gpu`` / ``--mpi`` flags.
|
|
806
|
+
Images must be pre-migrated (``podman-hpc migrate``).
|
|
807
|
+
|
|
808
|
+
If no container image is specified, the command runs directly (no
|
|
809
|
+
container wrapping).
|
|
810
|
+
"""
|
|
811
|
+
scheduler_config = scheduler_config or {}
|
|
812
|
+
job_name = f"lc_{output_id}_{universe_id}"
|
|
813
|
+
|
|
814
|
+
lines = ["#!/bin/bash"]
|
|
815
|
+
|
|
816
|
+
# Standard SBATCH header
|
|
817
|
+
lines.append(f"#SBATCH --job-name={job_name}")
|
|
818
|
+
|
|
819
|
+
# Output / error files go next to the script
|
|
820
|
+
lines.append(f"#SBATCH --output=results/.slurm/{output_id}_{universe_id}.out")
|
|
821
|
+
lines.append(f"#SBATCH --error=results/.slurm/{output_id}_{universe_id}.err")
|
|
822
|
+
|
|
823
|
+
# Resource directives
|
|
824
|
+
directives = translate_resources_to_slurm_directives(
|
|
825
|
+
resources, scheduler_config, resource_limits=resource_limits,
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
for d in directives:
|
|
829
|
+
lines.append(f"#SBATCH {d}")
|
|
830
|
+
|
|
831
|
+
lines.append("")
|
|
832
|
+
lines.append("# --- lightcone-cli / ASTRA recipe execution ---")
|
|
833
|
+
lines.append(f"cd {project_root}")
|
|
834
|
+
lines.append("")
|
|
835
|
+
|
|
836
|
+
# Build the execution command based on container runtime
|
|
837
|
+
if container and container_runtime == "podman-hpc":
|
|
838
|
+
lines.append(_podman_hpc_run_command(
|
|
839
|
+
command, container, project_root, resources, scheduler_config,
|
|
840
|
+
external_inputs=external_inputs,
|
|
841
|
+
))
|
|
842
|
+
else:
|
|
843
|
+
# No container — symlink external inputs into data/ directory
|
|
844
|
+
if external_inputs:
|
|
845
|
+
lines.append("mkdir -p data")
|
|
846
|
+
for input_id, source in sorted(external_inputs.items()):
|
|
847
|
+
lines.append(f"ln -sfn {source} data/{input_id}")
|
|
848
|
+
lines.append("")
|
|
849
|
+
# Run directly
|
|
850
|
+
lines.append(command)
|
|
851
|
+
|
|
852
|
+
lines.append("")
|
|
853
|
+
return "\n".join(lines)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _podman_hpc_run_command(
|
|
857
|
+
command: str,
|
|
858
|
+
container: str,
|
|
859
|
+
project_root: Path,
|
|
860
|
+
resources: dict[str, Any],
|
|
861
|
+
scheduler_config: dict[str, Any],
|
|
862
|
+
external_inputs: dict[str, str] | None = None,
|
|
863
|
+
) -> str:
|
|
864
|
+
"""Build a podman-hpc run invocation for use inside an sbatch script.
|
|
865
|
+
|
|
866
|
+
Key podman-hpc flags used:
|
|
867
|
+
- ``--rm``: Clean up container after exit.
|
|
868
|
+
- ``--gpu``: Bind NVIDIA GPU devices and drivers into the container.
|
|
869
|
+
- ``--mpi``: Inject Cray MPICH for optimized MPI on Slingshot.
|
|
870
|
+
- ``-v``: Volume mount the project root at /workspace.
|
|
871
|
+
- ``-w``: Set the working directory inside the container.
|
|
872
|
+
"""
|
|
873
|
+
parts = ["podman-hpc", "run", "--rm"]
|
|
874
|
+
|
|
875
|
+
# GPU support
|
|
876
|
+
if resources.get("gpus"):
|
|
877
|
+
parts.append("--gpu")
|
|
878
|
+
|
|
879
|
+
# MPI support — if the scheduler config opts in
|
|
880
|
+
container_flags = scheduler_config.get("container_flags", [])
|
|
881
|
+
if "--mpi" in container_flags:
|
|
882
|
+
parts.append("--mpi")
|
|
883
|
+
if "--nccl" in container_flags:
|
|
884
|
+
parts.append("--nccl")
|
|
885
|
+
if "--cuda-mpi" in container_flags:
|
|
886
|
+
parts.append("--cuda-mpi")
|
|
887
|
+
|
|
888
|
+
# Any extra user-specified flags
|
|
889
|
+
for flag in container_flags:
|
|
890
|
+
if flag not in ("--mpi", "--nccl", "--cuda-mpi", "--gpu"):
|
|
891
|
+
parts.append(flag)
|
|
892
|
+
|
|
893
|
+
# Volume mount project root
|
|
894
|
+
parts.extend(["-v", f"{project_root}:/workspace", "-w", "/workspace"])
|
|
895
|
+
|
|
896
|
+
# Read-only volume mounts for external inputs
|
|
897
|
+
for input_id, source in sorted((external_inputs or {}).items()):
|
|
898
|
+
parts.extend(["-v", f"{source}:/workspace/data/{input_id}:ro"])
|
|
899
|
+
|
|
900
|
+
parts.append(container)
|
|
901
|
+
parts.extend(["sh", "-c", _shell_quote(command)])
|
|
902
|
+
|
|
903
|
+
return " ".join(parts)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _shell_quote(s: str) -> str:
|
|
907
|
+
"""Wrap a string in single quotes for shell, escaping internal quotes."""
|
|
908
|
+
return shlex.quote(s)
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _parse_sbatch_job_id(stdout: str) -> str | None:
|
|
912
|
+
"""Extract job ID from sbatch output like 'Submitted batch job 12345'."""
|
|
913
|
+
match = re.search(r"Submitted batch job (\d+)", stdout)
|
|
914
|
+
return match.group(1) if match else None
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _poll_slurm_job(
|
|
918
|
+
job_id: str,
|
|
919
|
+
poll_interval: int = 15,
|
|
920
|
+
poll_timeout: int = 14400,
|
|
921
|
+
) -> tuple[int, dict[str, Any]]:
|
|
922
|
+
"""Poll a SLURM job until completion, returning (exit_code, metadata).
|
|
923
|
+
|
|
924
|
+
Uses ``sacct`` to query the final status. Falls back to ``squeue`` if
|
|
925
|
+
sacct is not available.
|
|
926
|
+
"""
|
|
927
|
+
start = time.monotonic()
|
|
928
|
+
metadata: dict[str, Any] = {}
|
|
929
|
+
|
|
930
|
+
while True:
|
|
931
|
+
elapsed = time.monotonic() - start
|
|
932
|
+
if elapsed > poll_timeout:
|
|
933
|
+
logger.warning(
|
|
934
|
+
"SLURM job %s timed out after %ds", job_id, poll_timeout,
|
|
935
|
+
)
|
|
936
|
+
metadata["timeout"] = True
|
|
937
|
+
return 1, metadata
|
|
938
|
+
|
|
939
|
+
# Check sacct for completed job
|
|
940
|
+
exit_code, meta = _check_sacct(job_id)
|
|
941
|
+
if exit_code is not None:
|
|
942
|
+
metadata.update(meta)
|
|
943
|
+
return exit_code, metadata
|
|
944
|
+
|
|
945
|
+
logger.debug(
|
|
946
|
+
"Job %s still running (%.0fs elapsed), polling in %ds",
|
|
947
|
+
job_id, elapsed, poll_interval,
|
|
948
|
+
)
|
|
949
|
+
time.sleep(poll_interval)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _check_sacct(job_id: str) -> tuple[int | None, dict[str, Any]]:
|
|
953
|
+
"""Query sacct for a completed job. Returns (exit_code, metadata) or (None, {})."""
|
|
954
|
+
try:
|
|
955
|
+
result = subprocess.run(
|
|
956
|
+
[
|
|
957
|
+
"sacct", "-j", job_id,
|
|
958
|
+
"--format=JobID,State,ExitCode,Elapsed,NodeList",
|
|
959
|
+
"--noheader", "--parsable2",
|
|
960
|
+
],
|
|
961
|
+
capture_output=True,
|
|
962
|
+
text=True,
|
|
963
|
+
)
|
|
964
|
+
except FileNotFoundError:
|
|
965
|
+
# sacct not available, try squeue fallback
|
|
966
|
+
return _check_squeue_fallback(job_id)
|
|
967
|
+
|
|
968
|
+
if result.returncode != 0:
|
|
969
|
+
return None, {}
|
|
970
|
+
|
|
971
|
+
for line in result.stdout.strip().splitlines():
|
|
972
|
+
parts = line.split("|")
|
|
973
|
+
if len(parts) < 5:
|
|
974
|
+
continue
|
|
975
|
+
sacct_job_id, state, exit_code_str, elapsed, nodelist = parts[:5]
|
|
976
|
+
# Only look at the main job step (not .batch, .extern, etc.)
|
|
977
|
+
if "." in sacct_job_id:
|
|
978
|
+
continue
|
|
979
|
+
|
|
980
|
+
state = state.strip()
|
|
981
|
+
if state in ("COMPLETED", "FAILED", "CANCELLED", "TIMEOUT", "NODE_FAIL",
|
|
982
|
+
"OUT_OF_MEMORY", "PREEMPTED"):
|
|
983
|
+
# For non-COMPLETED states always treat as failure — sacct often
|
|
984
|
+
# reports 0:0 for CANCELLED jobs which would be a false success.
|
|
985
|
+
if state != "COMPLETED":
|
|
986
|
+
exit_code = 1
|
|
987
|
+
else:
|
|
988
|
+
try:
|
|
989
|
+
exit_code = int(exit_code_str.split(":")[0])
|
|
990
|
+
except (ValueError, IndexError):
|
|
991
|
+
exit_code = 0
|
|
992
|
+
return exit_code, {
|
|
993
|
+
"slurm_state": state,
|
|
994
|
+
"elapsed": elapsed,
|
|
995
|
+
"nodelist": nodelist,
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
return None, {}
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _check_squeue_fallback(job_id: str) -> tuple[int | None, dict[str, Any]]:
|
|
1002
|
+
"""Fallback: use squeue to check if a job is still running."""
|
|
1003
|
+
try:
|
|
1004
|
+
result = subprocess.run(
|
|
1005
|
+
["squeue", "-j", job_id, "--noheader", "--format=%T"],
|
|
1006
|
+
capture_output=True,
|
|
1007
|
+
text=True,
|
|
1008
|
+
)
|
|
1009
|
+
except FileNotFoundError:
|
|
1010
|
+
logger.error("Neither sacct nor squeue found — cannot poll SLURM job")
|
|
1011
|
+
return 1, {"error": "sacct and squeue not found"}
|
|
1012
|
+
|
|
1013
|
+
state = result.stdout.strip()
|
|
1014
|
+
if not state:
|
|
1015
|
+
# Job no longer in queue — assume completed (sacct would be better)
|
|
1016
|
+
return 0, {"slurm_state": "COMPLETED (assumed)"}
|
|
1017
|
+
return None, {}
|