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/dispatch.py
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
"""The dispatched-eval primitive: an eval as its own Slurm job.
|
|
2
|
+
|
|
3
|
+
This module owns the three pieces docs/design/dispatcher.md specifies,
|
|
4
|
+
and nothing else:
|
|
5
|
+
|
|
6
|
+
* snapshot_tree — a retained snapshot of a dirty workspace, taken
|
|
7
|
+
against a TEMPORARY unique index seeded from the base commit (working
|
|
8
|
+
index untouched, tracked-vs-ignored parity with the drift fingerprint),
|
|
9
|
+
kept reachable under a unique ref so gc cannot prune it before a queued
|
|
10
|
+
job runs. Release with drop_snapshot after the eval is read.
|
|
11
|
+
* write_eval_job — the orchestrator-authored job script: materialize the
|
|
12
|
+
snapshot by CHECKOUT (git worktree add, then delete the .git gitfile so
|
|
13
|
+
the jail sees a plain faithful directory — checkout keeps .gitattributes
|
|
14
|
+
and applies no export processing), run the contract command under the
|
|
15
|
+
SAME jail as the in-job evaluator, capture stdout OUTSIDE the containment
|
|
16
|
+
into the run directory (the jailed process never sees the run dir).
|
|
17
|
+
* read_eval_result — the wake side: exit code + the same last-JSON-line
|
|
18
|
+
metric contract the in-job evaluator parses.
|
|
19
|
+
|
|
20
|
+
Dispatch is chosen per benchmark: `eval_minutes` is a contract HINT with
|
|
21
|
+
its own code-side ceiling; under the in-job threshold nothing here runs.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import math
|
|
30
|
+
import re
|
|
31
|
+
import shlex
|
|
32
|
+
import shutil
|
|
33
|
+
import subprocess
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from uuid import uuid4
|
|
37
|
+
|
|
38
|
+
from outerloop.compute import JobSpec
|
|
39
|
+
from outerloop.github import SAFE_GIT_FLAGS, GitError, Workspace, ensure_regular_git_dir
|
|
40
|
+
from outerloop.orchestrator import EvalError, _metric_from_output, managed_eval_env
|
|
41
|
+
|
|
42
|
+
log = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
# extra_env keys are exported unquoted into the job script; keep them to a
|
|
45
|
+
# shell identifier shape (the values ARE shlex-quoted).
|
|
46
|
+
_SHELL_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
47
|
+
|
|
48
|
+
# In-job evals must fit the climb job's overhead runway (a few minutes each,
|
|
49
|
+
# see limits.ATTEMPT_OVERHEAD_MINUTES); anything longer is dispatched.
|
|
50
|
+
IN_JOB_EVAL_MINUTES = 5
|
|
51
|
+
# Ceiling for the contract's eval_minutes hint: OUR spend cap, not the
|
|
52
|
+
# target's to raise (same grammar as every budget ceiling).
|
|
53
|
+
# A BACKSTOP, not a policy knob: the eval walltime is the contract's hint
|
|
54
|
+
# or the author's own declaration at submit, and spend is metered in
|
|
55
|
+
# GPU-hours against gpu_hours_per_run (syscall.budget_error). This only
|
|
56
|
+
# caps a runaway value.
|
|
57
|
+
EVAL_JOB_MINUTES_CEILING = 1440
|
|
58
|
+
# Slack added to the job walltime beyond the eval itself: worktree
|
|
59
|
+
# materialization + venv build from the lockfile on node-local scratch.
|
|
60
|
+
EVAL_JOB_SETUP_MINUTES = 10
|
|
61
|
+
# a GPU eval trains: data loading + torch.compile workers need real cores
|
|
62
|
+
# and host RAM; sized per GPU so an 8-GPU eval scales the same way
|
|
63
|
+
EVAL_CPUS_PER_GPU = 8
|
|
64
|
+
EVAL_MEM_GB_PER_GPU = 64
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def effective_eval_minutes(eval_minutes: int | None) -> int:
|
|
68
|
+
"""The contract hint clamped into [1, ceiling]; None means in-job."""
|
|
69
|
+
if eval_minutes is None:
|
|
70
|
+
return 0
|
|
71
|
+
return max(1, min(int(eval_minutes), EVAL_JOB_MINUTES_CEILING))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def should_dispatch(eval_minutes: int | None) -> bool:
|
|
75
|
+
return effective_eval_minutes(eval_minutes) > IN_JOB_EVAL_MINUTES
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def afterany_ids(afterany: str) -> list[str]:
|
|
79
|
+
"""The job ids inside an ``afterany:<id>:<id>...`` dependency string, or
|
|
80
|
+
``[]`` when there are none (a blind park carries an empty dependency)."""
|
|
81
|
+
return afterany.split(":")[1:] if afterany else []
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class Snapshot:
|
|
86
|
+
"""A retained snapshot of a dirty workspace. `ref` keeps the commit
|
|
87
|
+
reachable so gc cannot prune it while a queued job still needs it;
|
|
88
|
+
`tree` is the drift fingerprint. Release the ref via `drop_snapshot`
|
|
89
|
+
once the eval has been read."""
|
|
90
|
+
|
|
91
|
+
commit: str
|
|
92
|
+
tree: str
|
|
93
|
+
ref: str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def snapshot_tree(
|
|
97
|
+
ws: Workspace, base_sha: str, exclude: tuple[str, ...] = (), force: tuple[str, ...] = ()
|
|
98
|
+
) -> Snapshot:
|
|
99
|
+
"""Snapshot the workspace's current CONTENT as a commit parented on
|
|
100
|
+
`base_sha`, without touching the working index, and retain it under a
|
|
101
|
+
unique ref so gc cannot prune it before a queued job materializes it.
|
|
102
|
+
`exclude` drops those paths (files or whole directories) from the sealed
|
|
103
|
+
tree — research lines use it to keep the agent's memory out of every
|
|
104
|
+
MEASURABLE seal (gate candidates, launches) while the notebook seal
|
|
105
|
+
keeps it (docs/design/research-lines.md). `force` adds those paths even
|
|
106
|
+
when the target's ignore rules match them — the notebook seal uses it so
|
|
107
|
+
a .gitignore entry cannot silently discard session memory; callers pass
|
|
108
|
+
only paths that exist.
|
|
109
|
+
"""
|
|
110
|
+
# the snapshot writes an index, a tree, a commit, and a ref into this
|
|
111
|
+
# repository: a session-reshaped .git is refused first, like every other
|
|
112
|
+
# kernel git call on a workspace
|
|
113
|
+
ensure_regular_git_dir(Path(ws.root))
|
|
114
|
+
# Unique per snapshot: two snapshots against the SAME base run
|
|
115
|
+
# concurrently (the design's paired baseline/candidate fan-out) and must
|
|
116
|
+
# not collide on one index file.
|
|
117
|
+
token = uuid4().hex
|
|
118
|
+
index = Path(ws.root) / ".git" / f"dispatch-index-{token}"
|
|
119
|
+
ref = f"refs/dispatch/{token}"
|
|
120
|
+
env = {"GIT_INDEX_FILE": str(index)}
|
|
121
|
+
# The workspace is AGENT-WRITTEN: every git call carries SAFE_GIT_FLAGS
|
|
122
|
+
# (hooks/fsmonitor/ssh/credential neutralizers), drops HOME (no
|
|
123
|
+
# ~/.gitconfig read), and disables agent-defined filters via
|
|
124
|
+
# _filter_neutral_env (GIT_CONFIG_* env, which handles driver names with
|
|
125
|
+
# '=' or dots that `-c` cannot). The neutralizers ride EVERY index op
|
|
126
|
+
# because the clean filter runs lazily at write-tree, not add.
|
|
127
|
+
base_git = ["git", "-C", str(ws.root), *SAFE_GIT_FLAGS]
|
|
128
|
+
try:
|
|
129
|
+
# Neutralize agent-defined filters via GIT_CONFIG_KEY_n/VALUE_n env
|
|
130
|
+
# injection, NOT `-c`: a driver name containing '=' (a legal git
|
|
131
|
+
# subsection char) defeats `-c filter.<driver>.clean=cat` because git
|
|
132
|
+
# splits `-c` at the FIRST '='. The env form takes key and value as
|
|
133
|
+
# SEPARATE strings, immune to that — and to dots. See _filter_neutral_env.
|
|
134
|
+
neutral = _filter_neutral_env(base_git, env)
|
|
135
|
+
run_env = {**env, **neutral}
|
|
136
|
+
|
|
137
|
+
def run(args: list[str], timeout: int) -> str:
|
|
138
|
+
return subprocess.run(
|
|
139
|
+
args,
|
|
140
|
+
env=_git_env(run_env),
|
|
141
|
+
check=True,
|
|
142
|
+
capture_output=True,
|
|
143
|
+
text=True,
|
|
144
|
+
timeout=timeout,
|
|
145
|
+
).stdout.strip()
|
|
146
|
+
|
|
147
|
+
git = base_git # neutralizers now ride the ENV, not argv
|
|
148
|
+
|
|
149
|
+
# seed from the base so ignore rules apply as they do to a populated
|
|
150
|
+
# index (a fresh empty index would drop tracked-but-ignored files)
|
|
151
|
+
run([*git, "read-tree", base_sha], 60)
|
|
152
|
+
run([*git, "add", "-A"], 120)
|
|
153
|
+
if force:
|
|
154
|
+
run([*git, "add", "-f", "--", *force], 60)
|
|
155
|
+
if exclude:
|
|
156
|
+
run([*git, "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", *exclude], 60)
|
|
157
|
+
# .gitattributes are KEPT: the job materializes the tree by CHECKOUT
|
|
158
|
+
# (git worktree), which reproduces content faithfully — including
|
|
159
|
+
# .gitattributes — and does NOT apply export-ignore/export-subst
|
|
160
|
+
# (those are `git archive` only). So fidelity and integrity hold
|
|
161
|
+
# together; the filter side is already neutralized via GIT_CONFIG env.
|
|
162
|
+
tree = run([*git, "write-tree"], 60)
|
|
163
|
+
commit = run(
|
|
164
|
+
[
|
|
165
|
+
*git,
|
|
166
|
+
"-c",
|
|
167
|
+
"user.name=dispatch",
|
|
168
|
+
"-c",
|
|
169
|
+
"user.email=dispatch@localhost",
|
|
170
|
+
"commit-tree",
|
|
171
|
+
tree,
|
|
172
|
+
"-p",
|
|
173
|
+
base_sha,
|
|
174
|
+
"-m",
|
|
175
|
+
"dispatch snapshot",
|
|
176
|
+
],
|
|
177
|
+
60,
|
|
178
|
+
)
|
|
179
|
+
# retain: an unreachable commit-tree object can be pruned by gc while
|
|
180
|
+
# the eval job is still queued
|
|
181
|
+
run([*base_git, "update-ref", ref, commit], 30)
|
|
182
|
+
return Snapshot(commit=commit, tree=tree, ref=ref)
|
|
183
|
+
except subprocess.CalledProcessError as exc:
|
|
184
|
+
raise EvalError(f"snapshot failed: {exc.stderr.strip()[:300]}") from exc
|
|
185
|
+
except subprocess.TimeoutExpired as exc:
|
|
186
|
+
raise EvalError(f"snapshot timed out: {exc}") from exc
|
|
187
|
+
finally:
|
|
188
|
+
# the index and any .lock a timed-out git left beside it (unique per
|
|
189
|
+
# token, so it never blocks a future snapshot — just tidiness)
|
|
190
|
+
index.unlink(missing_ok=True)
|
|
191
|
+
index.with_name(index.name + ".lock").unlink(missing_ok=True)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _filter_neutral_env(base_git: list[str], env: dict[str, str]) -> dict[str, str]:
|
|
195
|
+
"""GIT_CONFIG_* env that overrides every configured filter driver to a
|
|
196
|
+
passthrough and disables attribute files. Robust where `-c` is not:
|
|
197
|
+
keys/values are separate env vars, so a driver name with '=' or dots is
|
|
198
|
+
handled correctly. Used by BOTH the snapshot (index ops) and the job
|
|
199
|
+
script's archive — the repo config and .gitattributes are agent-written
|
|
200
|
+
on both paths."""
|
|
201
|
+
pairs: list[tuple[str, str]] = [("core.attributesFile", "/dev/null")]
|
|
202
|
+
listing = subprocess.run(
|
|
203
|
+
[*base_git, "config", "-z", "--get-regexp", r"^filter\..*\.(clean|smudge|process)$"],
|
|
204
|
+
env=_git_env(env),
|
|
205
|
+
capture_output=True,
|
|
206
|
+
text=True,
|
|
207
|
+
timeout=30,
|
|
208
|
+
)
|
|
209
|
+
# -z: NUL-separated records, each "key\nvalue" — so a value containing a
|
|
210
|
+
# newline can never masquerade as a second record.
|
|
211
|
+
for record in listing.stdout.split("\0"):
|
|
212
|
+
key = record.split("\n", 1)[0]
|
|
213
|
+
if not key.startswith("filter.") or "." not in key[len("filter.") :]:
|
|
214
|
+
continue
|
|
215
|
+
driver = key[len("filter.") : key.rindex(".")]
|
|
216
|
+
pairs += [
|
|
217
|
+
(f"filter.{driver}.clean", "cat"),
|
|
218
|
+
(f"filter.{driver}.smudge", "cat"),
|
|
219
|
+
(f"filter.{driver}.process", ""),
|
|
220
|
+
]
|
|
221
|
+
out = {"GIT_CONFIG_COUNT": str(len(pairs))}
|
|
222
|
+
for i, (k, v) in enumerate(pairs):
|
|
223
|
+
out[f"GIT_CONFIG_KEY_{i}"] = k
|
|
224
|
+
out[f"GIT_CONFIG_VALUE_{i}"] = v
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def drop_snapshot(ws: Workspace, snapshot: Snapshot) -> None:
|
|
229
|
+
"""Release the retaining ref (the commit becomes gc-eligible again). Called
|
|
230
|
+
once the eval result has been read. Best-effort — it never RAISES, so a
|
|
231
|
+
caller's ending sequence cannot hinge on it — but not SILENT: a ref that
|
|
232
|
+
fails to drop keeps its commit alive forever, so the failure is logged.
|
|
233
|
+
A session-reshaped .git is not written to at all (a symlinked refs dir
|
|
234
|
+
would carry the ref deletion outside the workspace): logged and left."""
|
|
235
|
+
try:
|
|
236
|
+
ensure_regular_git_dir(Path(ws.root))
|
|
237
|
+
except GitError as exc:
|
|
238
|
+
log.warning("snapshot ref %s not dropped: %s", snapshot.ref, exc)
|
|
239
|
+
return
|
|
240
|
+
try:
|
|
241
|
+
result = subprocess.run(
|
|
242
|
+
["git", "-C", str(ws.root), *SAFE_GIT_FLAGS, "update-ref", "-d", snapshot.ref],
|
|
243
|
+
env=_git_env({}),
|
|
244
|
+
capture_output=True,
|
|
245
|
+
text=True,
|
|
246
|
+
timeout=30,
|
|
247
|
+
check=False,
|
|
248
|
+
)
|
|
249
|
+
if result.returncode != 0:
|
|
250
|
+
log.warning("snapshot ref drop failed for %s: %s", snapshot.ref, result.stderr[:200])
|
|
251
|
+
except Exception as exc:
|
|
252
|
+
log.warning("snapshot ref drop errored for %s: %s", snapshot.ref, exc)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _git_env(extra: dict[str, str]) -> dict[str, str]:
|
|
256
|
+
import os
|
|
257
|
+
|
|
258
|
+
# No HOME, and global/system config pinned to /dev/null: git must honor
|
|
259
|
+
# ONLY the repo's local config while operating on an agent-written tree.
|
|
260
|
+
# A globally-configured filter driver (in the user's ~/.gitconfig or the
|
|
261
|
+
# system config) could otherwise be SELECTED by an agent-authored
|
|
262
|
+
# .gitattributes and execute on the host — dropping HOME alone misses the
|
|
263
|
+
# system config and XDG paths.
|
|
264
|
+
env = {k: os.environ[k] for k in ("PATH", "LANG") if k in os.environ}
|
|
265
|
+
env["GIT_CONFIG_GLOBAL"] = "/dev/null"
|
|
266
|
+
env["GIT_CONFIG_SYSTEM"] = "/dev/null"
|
|
267
|
+
env.update(extra)
|
|
268
|
+
return env
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def write_eval_job(
|
|
272
|
+
run_dir: Path,
|
|
273
|
+
name: str,
|
|
274
|
+
*,
|
|
275
|
+
repo_root: Path,
|
|
276
|
+
snapshot_sha: str,
|
|
277
|
+
command: str,
|
|
278
|
+
image: str,
|
|
279
|
+
apptainer_binary: str = "apptainer",
|
|
280
|
+
extra_env: dict[str, str] | None = None,
|
|
281
|
+
artifacts: tuple[str, ...] = (),
|
|
282
|
+
artifact_max_bytes: int = 0,
|
|
283
|
+
gpus: int = 0,
|
|
284
|
+
) -> Path:
|
|
285
|
+
"""Write the orchestrator-authored job script for one dispatched eval.
|
|
286
|
+
|
|
287
|
+
`gpus` > 0 adds `--nv` to the jail so the job's allocated GPUs (the
|
|
288
|
+
JobSpec requests them) are visible inside the container; nothing else
|
|
289
|
+
about the containment changes.
|
|
290
|
+
|
|
291
|
+
Trust layout, matching the in-job evaluator exactly:
|
|
292
|
+
* the contract COMMAND goes into its own file, read back with
|
|
293
|
+
`sh -c "$(cat ...)"` INSIDE the jail — it never crosses sbatch or
|
|
294
|
+
shell quoting, and it executes only inside apptainer
|
|
295
|
+
--containall/--cleanenv with worktree-only binds;
|
|
296
|
+
* stdout/stderr are captured OUTSIDE the containment into the run
|
|
297
|
+
directory (the jailed process never sees the run dir);
|
|
298
|
+
* env is the evaluator's allowlist shape: uv cache + private venv on
|
|
299
|
+
node-local scratch, plus the call site's extra_env (paired seeds)
|
|
300
|
+
exported as APPTAINERENV_*.
|
|
301
|
+
Returns the script path; the caller submits it via JobSpec(script=...).
|
|
302
|
+
|
|
303
|
+
With `artifacts` (author-syscall launches, research-loop-buildout.md
|
|
304
|
+
Phase A), each declared repo-relative FILE the jailed command produced is
|
|
305
|
+
copied out of the throwaway tree into `<job dir>/artifacts/` — outside the
|
|
306
|
+
jail, after the command, size-capped at `artifact_max_bytes` — with every
|
|
307
|
+
skip recorded in `artifacts.log`. Callers validate the paths (relative, no
|
|
308
|
+
traversal) before passing them; this writer additionally quotes them so
|
|
309
|
+
they cross the script boundary inert.
|
|
310
|
+
"""
|
|
311
|
+
ev = run_dir / f"eval-{name}"
|
|
312
|
+
ev.mkdir(parents=True, exist_ok=True)
|
|
313
|
+
# a resubmitted eval must never be read as its predecessor: every prior
|
|
314
|
+
# artifact — including a leftover extracted tree — goes before submission
|
|
315
|
+
for stale in ("exit-code", "stdout", "stderr", "setup.log", "submitted", "artifacts.log"):
|
|
316
|
+
(ev / stale).unlink(missing_ok=True)
|
|
317
|
+
shutil.rmtree(ev / "tree", ignore_errors=True)
|
|
318
|
+
shutil.rmtree(ev / "artifacts", ignore_errors=True)
|
|
319
|
+
(ev / "command.txt").write_text(command)
|
|
320
|
+
# extra_env matches the in-job evaluator's contract: managed keys (HOME,
|
|
321
|
+
# UV_*, PATH...) are DROPPED, never allowed to override the isolation, and
|
|
322
|
+
# keys must be shell-identifier shaped (they are exported unquoted).
|
|
323
|
+
injected = {
|
|
324
|
+
k: v
|
|
325
|
+
for k, v in (extra_env or {}).items()
|
|
326
|
+
if _SHELL_IDENT.match(k) and not managed_eval_env(k)
|
|
327
|
+
}
|
|
328
|
+
safe_git = " ".join(shlex.quote(f) for f in SAFE_GIT_FLAGS)
|
|
329
|
+
# the checkout runs on the agent-written repo too: same filter neutralizers
|
|
330
|
+
# as the snapshot, injected as GIT_CONFIG_* env (robust to '=' in a driver
|
|
331
|
+
# name, unlike -c) so a smudge filter cannot execute during checkout
|
|
332
|
+
neutral = _filter_neutral_env(["git", "-C", str(repo_root), *SAFE_GIT_FLAGS], {})
|
|
333
|
+
lines = [
|
|
334
|
+
"#!/bin/sh",
|
|
335
|
+
"set -u",
|
|
336
|
+
f"EV={shlex.quote(str(ev))}",
|
|
337
|
+
f"REPO={shlex.quote(str(repo_root))}",
|
|
338
|
+
# the extracted tree lives on NODE-LOCAL scratch, not the shared run
|
|
339
|
+
# dir: it dies with the job (nothing to reap on the shared FS), and
|
|
340
|
+
# the wake needs only stdout/exit-code, which stay in $EV
|
|
341
|
+
'SCRATCH="${SLURM_TMPDIR:-${TMPDIR:-/tmp}}/dispatch-eval-$$"',
|
|
342
|
+
'TREE="$SCRATCH/tree"',
|
|
343
|
+
'mkdir -p "$SCRATCH/cache" "$SCRATCH/home" "$SCRATCH/work" "$TREE"',
|
|
344
|
+
# trap FIRST, before anything that can exit, so $SCRATCH never leaks.
|
|
345
|
+
# prune reaps the stale worktree admin entry in $REPO/.git/worktrees
|
|
346
|
+
# left when we deleted $TREE/.git (worktree remove can't run without it)
|
|
347
|
+
'cleanup() { rm -rf "$SCRATCH"; '
|
|
348
|
+
f'git -C "$REPO" {safe_git} worktree prune >/dev/null 2>&1 || true; }}',
|
|
349
|
+
"trap 'cleanup' EXIT",
|
|
350
|
+
"trap 'echo 143 > \"$EV/exit-code\"; cleanup; trap - EXIT; exit 0' TERM INT HUP",
|
|
351
|
+
# the command file must exist and be non-empty, or sh -c "" would
|
|
352
|
+
# exit 0 with empty output and read as a clean eval that measured
|
|
353
|
+
# nothing
|
|
354
|
+
'[ -s "$EV/command.txt" ] || { echo 96 > "$EV/exit-code"; exit 0; }',
|
|
355
|
+
# git on the node runs with the job user's HOME present, so pin global
|
|
356
|
+
# AND system config to /dev/null: a globally-configured filter driver
|
|
357
|
+
# could otherwise be selected by the agent-authored .gitattributes and
|
|
358
|
+
# execute here, outside the jail. Local repo config still applies and
|
|
359
|
+
# is neutralized by the GIT_CONFIG_KEY_* overrides below.
|
|
360
|
+
"export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null",
|
|
361
|
+
]
|
|
362
|
+
for k, v in neutral.items():
|
|
363
|
+
lines.append(f"export {k}={shlex.quote(v)}")
|
|
364
|
+
lines += [
|
|
365
|
+
# Materialize the snapshot by CHECKOUT, not `git archive`: a checkout
|
|
366
|
+
# reproduces content faithfully — INCLUDING .gitattributes — and does
|
|
367
|
+
# NOT apply export-ignore/export-subst (archive-only), so the measured
|
|
368
|
+
# tree equals both the workspace (fidelity) and the fingerprint
|
|
369
|
+
# (integrity). Smudge filters during checkout are neutralized by the
|
|
370
|
+
# GIT_CONFIG_* env above. The worktree's .git gitfile (which would
|
|
371
|
+
# point into $REPO/.git/worktrees, unbound in the jail) is DELETED
|
|
372
|
+
# after checkout, leaving a plain directory; the stale admin entry is
|
|
373
|
+
# pruned on cleanup.
|
|
374
|
+
f'if git -C "$REPO" {safe_git} worktree add --detach "$TREE" '
|
|
375
|
+
f'{shlex.quote(snapshot_sha)} >> "$EV/setup.log" 2>&1; then '
|
|
376
|
+
'rm -f "$TREE/.git"; ' # plain dir now — nothing points back into $REPO
|
|
377
|
+
'else echo 97 > "$EV/exit-code"; exit 0; fi',
|
|
378
|
+
'export UV_CACHE_DIR="$SCRATCH/cache" UV_LINK_MODE=copy '
|
|
379
|
+
'UV_PROJECT_ENVIRONMENT="$SCRATCH/cache/venv"',
|
|
380
|
+
'export APPTAINERENV_UV_CACHE_DIR="$UV_CACHE_DIR" '
|
|
381
|
+
"APPTAINERENV_UV_LINK_MODE=copy "
|
|
382
|
+
'APPTAINERENV_UV_PROJECT_ENVIRONMENT="$UV_PROJECT_ENVIRONMENT"',
|
|
383
|
+
]
|
|
384
|
+
for key, value in injected.items():
|
|
385
|
+
lines.append(f"export {key}={shlex.quote(value)} APPTAINERENV_{key}={shlex.quote(value)}")
|
|
386
|
+
if image:
|
|
387
|
+
lines += [
|
|
388
|
+
# the jail: identical flags to SubprocessEvaluator._run; stdout is
|
|
389
|
+
# redirected OUTSIDE apptainer, so the result lands in the run dir
|
|
390
|
+
# without the jailed process ever seeing it
|
|
391
|
+
f"{shlex.quote(apptainer_binary)} exec --containall --cleanenv "
|
|
392
|
+
+ ("--nv " if gpus > 0 else "")
|
|
393
|
+
+ '--bind "$TREE:$TREE" --home "$SCRATCH/home:$SCRATCH/home" '
|
|
394
|
+
'--bind "$SCRATCH/cache:$SCRATCH/cache" --pwd "$TREE" '
|
|
395
|
+
# /tmp and /var/tmp inside the jail live on node-local scratch,
|
|
396
|
+
# not apptainer's small tmpfs: a command that writes compile caches
|
|
397
|
+
# or checkpoints to /tmp (any training script that was not told
|
|
398
|
+
# otherwise) must not die on a temp-disk limit
|
|
399
|
+
'--workdir "$SCRATCH/work" '
|
|
400
|
+
f"{shlex.quote(image)} "
|
|
401
|
+
'sh -c "$(cat "$EV/command.txt")" '
|
|
402
|
+
'> "$EV/stdout" 2> "$EV/stderr"',
|
|
403
|
+
'echo $? > "$EV/exit-code"',
|
|
404
|
+
]
|
|
405
|
+
else:
|
|
406
|
+
# UNCONTAINED (dev/tests, no image): run directly in the throwaway
|
|
407
|
+
# tree under `env -i` with the same allowlist shape the uncontained
|
|
408
|
+
# evaluator used — the submitting process's env (which can hold live
|
|
409
|
+
# keys) must never reach the agent-authored command
|
|
410
|
+
bare_env = " ".join(
|
|
411
|
+
[
|
|
412
|
+
'HOME="$SCRATCH/home"',
|
|
413
|
+
'PATH="$PATH"',
|
|
414
|
+
'LANG="${LANG:-C.UTF-8}"',
|
|
415
|
+
'TMPDIR="$SCRATCH"',
|
|
416
|
+
'UV_CACHE_DIR="$UV_CACHE_DIR"',
|
|
417
|
+
"UV_LINK_MODE=copy",
|
|
418
|
+
'UV_PROJECT_ENVIRONMENT="$UV_PROJECT_ENVIRONMENT"',
|
|
419
|
+
]
|
|
420
|
+
+ [f"{key}={shlex.quote(value)}" for key, value in injected.items()]
|
|
421
|
+
)
|
|
422
|
+
lines += [
|
|
423
|
+
f'cd "$TREE" && env -i {bare_env} '
|
|
424
|
+
'sh -c "$(cat "$EV/command.txt")" '
|
|
425
|
+
'> "$EV/stdout" 2> "$EV/stderr"',
|
|
426
|
+
'echo $? > "$EV/exit-code"',
|
|
427
|
+
]
|
|
428
|
+
if artifacts:
|
|
429
|
+
# copy-out runs OUTSIDE the jail, after the command: only declared,
|
|
430
|
+
# caller-validated repo-relative FILES, each size-capped; every skip is
|
|
431
|
+
# recorded so the wake can tell the author exactly what happened.
|
|
432
|
+
# Two hostile shapes to defeat, since the jailed (agent) command wrote
|
|
433
|
+
# this tree and picked these names:
|
|
434
|
+
# * INJECTION — a name like `$(cmd)` must never reach the shell as
|
|
435
|
+
# code. Every path goes in single-quoted (shlex.quote) argument
|
|
436
|
+
# position, and every log line is a single-quoted LITERAL (a name
|
|
437
|
+
# interpolated into a double-quoted `echo` would execute), so the
|
|
438
|
+
# name is inert everywhere.
|
|
439
|
+
# * ESCAPE via symlink — a declared file (or an intermediate dir) may
|
|
440
|
+
# be a symlink to a host file; the host-side cp would dereference
|
|
441
|
+
# it. `realpath` resolves the whole path and we copy ONLY when the
|
|
442
|
+
# resolved target stays under $TREE (and copy the RESOLVED path, so
|
|
443
|
+
# there is no resolve-then-copy gap). The command has already
|
|
444
|
+
# finished, so the tree is quiescent — no TOCTOU.
|
|
445
|
+
lines.append('mkdir -p "$EV/artifacts"')
|
|
446
|
+
lines.append('TREE_REAL=$(realpath "$TREE" 2>/dev/null || echo "$TREE")')
|
|
447
|
+
for art in artifacts:
|
|
448
|
+
q = shlex.quote(art) # safe in argument position (single-quoted)
|
|
449
|
+
skip_type = shlex.quote(f"skipped (not a regular file in the tree): {art}")
|
|
450
|
+
skip_big = shlex.quote(f"skipped (over {int(artifact_max_bytes)} bytes): {art}")
|
|
451
|
+
fail_cp = shlex.quote(f"copy failed: {art}")
|
|
452
|
+
lines.append(
|
|
453
|
+
f'AP=$(realpath "$TREE"/{q} 2>/dev/null || true); '
|
|
454
|
+
f'case "$AP" in "$TREE_REAL"/*) '
|
|
455
|
+
f'if [ -f "$AP" ] && [ "$(wc -c < "$AP")" -le {int(artifact_max_bytes)} ]; then '
|
|
456
|
+
f'mkdir -p "$EV/artifacts/$(dirname {q})" && cp "$AP" "$EV/artifacts"/{q} '
|
|
457
|
+
f'|| echo {fail_cp} >> "$EV/artifacts.log"; '
|
|
458
|
+
f'elif [ -f "$AP" ]; then echo {skip_big} >> "$EV/artifacts.log"; '
|
|
459
|
+
f'else echo {skip_type} >> "$EV/artifacts.log"; fi ;; '
|
|
460
|
+
f'*) echo {skip_type} >> "$EV/artifacts.log" ;; esac'
|
|
461
|
+
)
|
|
462
|
+
lines += [
|
|
463
|
+
"exit 0",
|
|
464
|
+
]
|
|
465
|
+
script = ev / "job.sh"
|
|
466
|
+
script.write_text("\n".join(lines) + "\n")
|
|
467
|
+
script.chmod(0o755)
|
|
468
|
+
return script
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def eval_job_spec(
|
|
472
|
+
script: Path,
|
|
473
|
+
*,
|
|
474
|
+
job_name: str,
|
|
475
|
+
account: str,
|
|
476
|
+
partition: str,
|
|
477
|
+
eval_minutes: int,
|
|
478
|
+
cpus: int = 4,
|
|
479
|
+
mem: str = "8G",
|
|
480
|
+
gpus: int = 0,
|
|
481
|
+
) -> JobSpec:
|
|
482
|
+
"""The JobSpec for one dispatched eval: the hint CLAMPED to our ceiling
|
|
483
|
+
plus setup slack — a contract value above EVAL_JOB_MINUTES_CEILING must
|
|
484
|
+
not create a longer Slurm job than the ceiling allows. `gpus` is the
|
|
485
|
+
benchmark's contract field; the caller has already placed the job on
|
|
486
|
+
the GPU lane (DispatchSettings.placement) when it is nonzero, and the
|
|
487
|
+
job is sized for it: a GPU eval gets at least EVAL_CPUS_PER_GPU cores
|
|
488
|
+
and EVAL_MEM_GB_PER_GPU GB per GPU (a training eval's data loading and
|
|
489
|
+
torch.compile workers do not fit the CPU eval's 4 cores / 8 GB)."""
|
|
490
|
+
if gpus > 0:
|
|
491
|
+
cpus = max(cpus, EVAL_CPUS_PER_GPU * gpus)
|
|
492
|
+
given = _mem_gb(mem)
|
|
493
|
+
# an explicit request is never SHRUNK: only a parseable value below
|
|
494
|
+
# the per-GPU floor is raised; anything unparseable passes through
|
|
495
|
+
if given is not None and given < EVAL_MEM_GB_PER_GPU * gpus:
|
|
496
|
+
mem = f"{EVAL_MEM_GB_PER_GPU * gpus}G"
|
|
497
|
+
return JobSpec(
|
|
498
|
+
job_name=job_name[:60],
|
|
499
|
+
account=account,
|
|
500
|
+
partition=partition,
|
|
501
|
+
time_minutes=effective_eval_minutes(eval_minutes) + EVAL_JOB_SETUP_MINUTES,
|
|
502
|
+
script=str(script),
|
|
503
|
+
cpus=cpus,
|
|
504
|
+
mem=mem,
|
|
505
|
+
gpus=gpus,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _mem_gb(mem: str) -> int | None:
|
|
510
|
+
"""A Slurm --mem value ("8G", "512M", "1T", "16") in whole GB, rounded
|
|
511
|
+
down; None when unparseable (the caller then leaves it alone)."""
|
|
512
|
+
text = mem.strip().upper()
|
|
513
|
+
scale = {"K": 1 / (1024 * 1024), "M": 1 / 1024, "G": 1.0, "T": 1024.0}
|
|
514
|
+
unit = text[-1:] if text[-1:] in scale else ""
|
|
515
|
+
number = text[:-1] if unit else text
|
|
516
|
+
try:
|
|
517
|
+
value = float(number)
|
|
518
|
+
except ValueError:
|
|
519
|
+
return None
|
|
520
|
+
if not math.isfinite(value) or value < 0:
|
|
521
|
+
return None # "nanG"/"infG": not a size — pass it through untouched
|
|
522
|
+
return int(value * (scale[unit] if unit else 1 / 1024)) # bare Slurm --mem is MB
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def read_eval_result(run_dir: Path, name: str, metric: str) -> float:
|
|
526
|
+
"""The wake side of one dispatched eval. Raises EvalError with the same
|
|
527
|
+
semantics as the in-job evaluator: nonzero exit or an unreadable metric
|
|
528
|
+
is an eval failure (outcome `eval-error`, ending `aborted`), and a
|
|
529
|
+
MISSING exit-code file means the job died before the wrapper ran —
|
|
530
|
+
also a failure, never a silent skip."""
|
|
531
|
+
ev = run_dir / f"eval-{name}"
|
|
532
|
+
try:
|
|
533
|
+
code = int((ev / "exit-code").read_text().strip())
|
|
534
|
+
except (OSError, ValueError) as exc:
|
|
535
|
+
raise EvalError(f"dispatched eval {name}: no exit code ({exc})") from exc
|
|
536
|
+
stdout = ""
|
|
537
|
+
with contextlib.suppress(OSError, ValueError):
|
|
538
|
+
stdout = (ev / "stdout").read_text(errors="replace")
|
|
539
|
+
if code != 0:
|
|
540
|
+
tail = ""
|
|
541
|
+
with contextlib.suppress(OSError, ValueError):
|
|
542
|
+
tail = (ev / "stderr").read_text(errors="replace")[-300:]
|
|
543
|
+
raise EvalError(f"dispatched eval {name} failed ({code}): {tail}")
|
|
544
|
+
value = _metric_from_output(stdout, metric)
|
|
545
|
+
if value is None:
|
|
546
|
+
raise EvalError(f"dispatched eval {name}: no readable {metric!r} in output")
|
|
547
|
+
if not math.isfinite(value):
|
|
548
|
+
# same rule as the in-job evaluator: json parses bare NaN/Infinity,
|
|
549
|
+
# and a NaN score entering a comparison is worse than a failure
|
|
550
|
+
raise EvalError(f"dispatched eval {name}: non-finite {metric!r} ({value})")
|
|
551
|
+
return value
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def result_summary(run_dir: Path, name: str) -> str:
|
|
555
|
+
"""One line for reports/logs; never raises."""
|
|
556
|
+
ev = run_dir / f"eval-{name}"
|
|
557
|
+
try:
|
|
558
|
+
code = (ev / "exit-code").read_text(errors="replace").strip()
|
|
559
|
+
except (OSError, ValueError):
|
|
560
|
+
code = "?"
|
|
561
|
+
try:
|
|
562
|
+
last = [
|
|
563
|
+
ln for ln in (ev / "stdout").read_text(errors="replace").splitlines() if ln.strip()
|
|
564
|
+
][-1]
|
|
565
|
+
except (OSError, ValueError, IndexError):
|
|
566
|
+
last = ""
|
|
567
|
+
return f"eval-{name}: exit={code} {last[:160]}"
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def parse_result_json(run_dir: Path, name: str) -> dict:
|
|
571
|
+
"""The full JSON line (margins, per-encoder blocks) for report embedding;
|
|
572
|
+
empty dict when absent."""
|
|
573
|
+
ev = run_dir / f"eval-{name}"
|
|
574
|
+
try:
|
|
575
|
+
for line in reversed((ev / "stdout").read_text(errors="replace").splitlines()):
|
|
576
|
+
line = line.strip()
|
|
577
|
+
if line.startswith("{"):
|
|
578
|
+
try:
|
|
579
|
+
data = json.loads(line)
|
|
580
|
+
except json.JSONDecodeError:
|
|
581
|
+
continue
|
|
582
|
+
if isinstance(data, dict):
|
|
583
|
+
return data
|
|
584
|
+
except OSError:
|
|
585
|
+
pass
|
|
586
|
+
return {}
|