outerloop-science 0.1.0.dev0__py3-none-any.whl → 0.1.0.dev2__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 +7 -7
- outerloop/appauth.py +3 -3
- outerloop/appmanifest.py +16 -11
- outerloop/attempt.py +80 -52
- outerloop/cli.py +170 -86
- outerloop/climbboard.py +209 -4
- outerloop/compute.py +84 -7
- outerloop/contract.py +3 -2
- outerloop/followup.py +37 -23
- outerloop/github.py +4 -4
- outerloop/harness.py +18 -7
- outerloop/image.py +372 -0
- outerloop/init.py +417 -29
- outerloop/limits.py +1 -1
- outerloop/measure.py +2 -2
- outerloop/orchestrator.py +1 -1
- outerloop/paths.py +14 -1
- outerloop/review.py +1 -1
- outerloop/steward.py +3 -3
- outerloop/syscall.py +1 -0
- outerloop/tick.py +400 -105
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.dist-info}/METADATA +14 -10
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.dist-info}/RECORD +27 -26
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.dist-info}/WHEEL +0 -0
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.dist-info}/entry_points.txt +0 -0
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.dist-info}/licenses/LICENSE +0 -0
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.dist-info}/licenses/NOTICE +0 -0
outerloop/tick.py
CHANGED
|
@@ -21,6 +21,7 @@ import os
|
|
|
21
21
|
import re
|
|
22
22
|
import socket
|
|
23
23
|
import subprocess
|
|
24
|
+
import sys
|
|
24
25
|
from collections.abc import Sequence
|
|
25
26
|
from dataclasses import asdict, dataclass, field, replace
|
|
26
27
|
from pathlib import Path
|
|
@@ -35,6 +36,7 @@ from outerloop.compute import (
|
|
|
35
36
|
SlurmError,
|
|
36
37
|
SlurmQueryError,
|
|
37
38
|
compute_from_env,
|
|
39
|
+
gpus_in_gres,
|
|
38
40
|
is_pending,
|
|
39
41
|
is_terminal,
|
|
40
42
|
local_mode,
|
|
@@ -73,7 +75,7 @@ log = logging.getLogger(__name__)
|
|
|
73
75
|
PAUSE_SENTINEL = "PAUSE"
|
|
74
76
|
# Operator on-switch for dispatched-wake, mirroring PAUSE: a root-relative
|
|
75
77
|
# sentinel an operator arms/disarms with a touch/rm — no chain restart, no
|
|
76
|
-
# env-var surgery on a live tick. The
|
|
78
|
+
# env-var surgery on a live tick. The OUTERLOOP_DISPATCH_WAKE env var still
|
|
77
79
|
# works too (either arms it); the sentinel is the reversible, restart-free path.
|
|
78
80
|
DISPATCH_WAKE_SENTINEL = "DISPATCH_WAKE"
|
|
79
81
|
HEARTBEAT_NAME = "heartbeat.json"
|
|
@@ -87,6 +89,34 @@ DEFAULT_GRACE_S = 15 * 60
|
|
|
87
89
|
# a blind park (no job ids to poll) waits its eval walltime plus this queue
|
|
88
90
|
# slack before a follow-up is sent to look for the result
|
|
89
91
|
BLIND_PARK_SLACK_MIN = 12 * 60
|
|
92
|
+
|
|
93
|
+
# Slurm pending reasons that mean "the job will run when its turn comes": the
|
|
94
|
+
# queue is busy, a reservation or dependency is ahead of it, or the account's
|
|
95
|
+
# OWN jobs hold a per-user/per-account/group cap that clears as they finish. A
|
|
96
|
+
# job in this state keeps accruing priority age; cancelling and resubmitting
|
|
97
|
+
# would put it at the back of the queue behind itself. Per-job limits
|
|
98
|
+
# (`...PerJob...`), a dependency that can never be satisfied, an invalid
|
|
99
|
+
# account/QOS or a held job are NOT waits: those never clear on their own.
|
|
100
|
+
# JobHeldUser is the kernel's own launch admission (service_admission): a
|
|
101
|
+
# held launch waits for a GPU slot, and an operator's hold is theirs to lift.
|
|
102
|
+
QUEUE_WAIT_REASONS = frozenset(
|
|
103
|
+
{"Priority", "Resources", "Reservation", "Dependency", "ReqNodeNotAvail", "JobHeldUser"}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def is_queue_wait(reason: str) -> bool:
|
|
108
|
+
"""Whether a squeue pending reason describes a healthy wait (see
|
|
109
|
+
QUEUE_WAIT_REASONS). Reasons arrive as `Name` or `Name, detail...`."""
|
|
110
|
+
head = reason.strip().split(",")[0].split(" ")[0].strip()
|
|
111
|
+
if not head:
|
|
112
|
+
return False
|
|
113
|
+
if head in QUEUE_WAIT_REASONS:
|
|
114
|
+
return True
|
|
115
|
+
if "PerJob" in head:
|
|
116
|
+
return False
|
|
117
|
+
return any(tag in head for tag in ("PerUser", "PerAccount", "Grp"))
|
|
118
|
+
|
|
119
|
+
|
|
90
120
|
# A held lease is stale after the session timeout plus slack.
|
|
91
121
|
DEFAULT_LEASE_TTL_S = 3600 + 15 * 60
|
|
92
122
|
# Coalesce guard: skip a tick's work if another ran within this window. Under
|
|
@@ -94,7 +124,7 @@ DEFAULT_LEASE_TTL_S = 3600 + 15 * 60
|
|
|
94
124
|
# (serialized by the singleton dependency), so they would run back-to-back and
|
|
95
125
|
# redundantly re-sweep. The chain schedules ticks a full cadence apart by
|
|
96
126
|
# begin-time, so only late-bunched pile-ups fall inside this window; keep it
|
|
97
|
-
# well BELOW the cadence (default 30 min). 0 disables. Env:
|
|
127
|
+
# well BELOW the cadence (default 30 min). 0 disables. Env: OUTERLOOP_MIN_TICK_MINUTES.
|
|
98
128
|
DEFAULT_MIN_TICK_S = 10 * 60
|
|
99
129
|
# Ceiling for the coalesce window: a value above this is almost certainly a typo
|
|
100
130
|
# (a window near/over the cadence would coalesce every on-cadence tick and stall
|
|
@@ -153,8 +183,8 @@ class TickReport:
|
|
|
153
183
|
# The submitted walltime must never exceed the job partition's MaxTime —
|
|
154
184
|
# sbatch REJECTS a longer request outright, which would ground every climb.
|
|
155
185
|
# The DEFAULT matches cpu_short (6 h); an operator moving work jobs to a
|
|
156
|
-
# longer partition (
|
|
157
|
-
#
|
|
186
|
+
# longer partition (OUTERLOOP_JOB_PARTITION=cpu48) raises the cap with
|
|
187
|
+
# OUTERLOOP_MAX_JOB_MINUTES. Code-side ceiling: the cap must stay under
|
|
158
188
|
# STRANDED_IMPLEMENTING_S or the picker declares live runs stranded — jobs
|
|
159
189
|
# longer than 10 h need that window made spec-aware first (named gap). The
|
|
160
190
|
# self-deadline arms at the CLAMPED value, so a job that wanted more time
|
|
@@ -180,12 +210,12 @@ class FollowupSpec:
|
|
|
180
210
|
partition: str
|
|
181
211
|
run_root: Path
|
|
182
212
|
image: str
|
|
183
|
-
home: Path #
|
|
213
|
+
home: Path # OUTERLOOP_HOME: cwd for the submitted job
|
|
184
214
|
bot_login: str = field(default_factory=_bot_login_default)
|
|
185
215
|
time_minutes: int = 90 # min()'d with the contract's followup_job_minutes
|
|
186
216
|
max_turns: int = DEFAULT_MAX_TURNS # session turn budget for follow-up jobs
|
|
187
217
|
pat_file: str = "" # forwarded to the job; "" = the followup CLI default
|
|
188
|
-
# GitHub App config path; jobs inherit
|
|
218
|
+
# GitHub App config path; jobs inherit OUTERLOOP_GITHUB_APP_FILE from
|
|
189
219
|
# the tick environment, so it is never threaded through argv
|
|
190
220
|
github_app_file: str = ""
|
|
191
221
|
target: str = "" # the repo the intake pass scans for requested-lane issues
|
|
@@ -193,7 +223,7 @@ class FollowupSpec:
|
|
|
193
223
|
# until the operator provisions it
|
|
194
224
|
steward_key_file: str = ""
|
|
195
225
|
# Pre-PR verification panel for climb jobs (docs/design/orchestrator-verify.md).
|
|
196
|
-
# DEFAULT ON — the flip is code, the off-switch is
|
|
226
|
+
# DEFAULT ON — the flip is code, the off-switch is OUTERLOOP_PANEL="".
|
|
197
227
|
# The climb CLI fails LOUDLY on a bad panel config (a configured gate must
|
|
198
228
|
# never silently vanish); the tick preflights the same rules — lens
|
|
199
229
|
# grammar AND key file — before claiming or submitting so nothing is
|
|
@@ -214,6 +244,11 @@ class FollowupSpec:
|
|
|
214
244
|
# are minutes, work jobs can be hours, and Slurm prices walltime into
|
|
215
245
|
# scheduling priority, so the two deserve independent placement.
|
|
216
246
|
job_partition: str = ""
|
|
247
|
+
# Launch admission: the most GPUs this user's jobs may hold at once
|
|
248
|
+
# (OUTERLOOP_MAX_LAUNCH_GPUS, e.g. the QOS's MaxTRESPerUser). 0 = off:
|
|
249
|
+
# launches queue as submitted. On, GPU launches are submitted held and
|
|
250
|
+
# service_admission releases them oldest-first under the cap.
|
|
251
|
+
max_launch_gpus: int = 0
|
|
217
252
|
# Partition MaxTime for work jobs — the panel-augmented walltime clamps
|
|
218
253
|
# here (see MAX_ATTEMPT_JOB_MINUTES). Raise together with job_partition.
|
|
219
254
|
max_job_minutes: int = MAX_ATTEMPT_JOB_MINUTES
|
|
@@ -234,6 +269,10 @@ def flight_checkout(home: Path, name: str, now: float) -> Path:
|
|
|
234
269
|
uncommitted hand-edits in the shared checkout do not fly — only
|
|
235
270
|
deployed code does. Failures fall back to the shared checkout — a
|
|
236
271
|
snapshot must never ground the fleet."""
|
|
272
|
+
if not (home / ".git").exists():
|
|
273
|
+
# not a checkout (the local loop on an installed package): nothing to
|
|
274
|
+
# pin, the job runs from the home directory itself
|
|
275
|
+
return home
|
|
237
276
|
flights = home.parent / "flights"
|
|
238
277
|
try:
|
|
239
278
|
flights.mkdir(parents=True, exist_ok=True)
|
|
@@ -281,11 +320,30 @@ def _gpu_lane_error(contract: Any, benchmark: str, spec: FollowupSpec) -> str:
|
|
|
281
320
|
if gpu_benches:
|
|
282
321
|
return (
|
|
283
322
|
f"contract has GPU benchmarks ({', '.join(gpu_benches)}) but no GPU lane is "
|
|
284
|
-
"configured (set
|
|
323
|
+
"configured (set OUTERLOOP_GPU_PARTITION)"
|
|
285
324
|
)
|
|
286
325
|
return ""
|
|
287
326
|
|
|
288
327
|
|
|
328
|
+
_UNCONTAINED_WARNED = False # the local-mode warning is said once per process
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _containment(image: str) -> list[str]:
|
|
332
|
+
"""The job's containment flags: the image when there is one, else the
|
|
333
|
+
explicit uncontained flag every entry point requires in its absence."""
|
|
334
|
+
return ["--image", image] if image else ["--uncontained"]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _interpreter(home: Path) -> list[str]:
|
|
338
|
+
"""How a job runs Python. From a source checkout, `uv run python` resolves
|
|
339
|
+
the project's own environment, as the cluster's flights do. From a plain
|
|
340
|
+
home directory (the local loop on an installed package) the job runs the
|
|
341
|
+
interpreter this tick runs under, which is where the package is."""
|
|
342
|
+
if (home / "pyproject.toml").is_file():
|
|
343
|
+
return ["uv", "run", "python"]
|
|
344
|
+
return [sys.executable]
|
|
345
|
+
|
|
346
|
+
|
|
289
347
|
def _flight_command(home: Path, job_name: str, now: float, argv: list[str]) -> str:
|
|
290
348
|
"""The job's shell command, cd'ing into a fresh flight snapshot.
|
|
291
349
|
|
|
@@ -682,8 +740,29 @@ def service_in_review(
|
|
|
682
740
|
except SlurmQueryError:
|
|
683
741
|
continue # unknown — do not stack another job
|
|
684
742
|
# the wake-attempt counter caps follow-up retries too: a responder
|
|
685
|
-
# that cannot advance its cursors must not burn a session per tick
|
|
686
|
-
|
|
743
|
+
# that cannot advance its cursors must not burn a session per tick.
|
|
744
|
+
# A LANDED re-measure gets its own allowance: the sessions that
|
|
745
|
+
# synced the base and dispatched it were progress, and capping the
|
|
746
|
+
# follow-up that pushes the sealed number parks the result forever
|
|
747
|
+
# (speedrun agent-03, 2026-09-04: three syncs spent the cap, the
|
|
748
|
+
# measure completed, and the run idled for two days on this
|
|
749
|
+
# branch). The allowance is counted on the stage itself, so a
|
|
750
|
+
# finishing session that reverts and leaves the stage intact is
|
|
751
|
+
# retried MAX_WAKE_ATTEMPTS times, not once per tick; a stage that
|
|
752
|
+
# finishes is cleared, and with it the count.
|
|
753
|
+
finish_attempts = (
|
|
754
|
+
int(record.followup_stage.get("finish_attempts", 0) or 0) # type: ignore[call-overload]
|
|
755
|
+
if measure_ready
|
|
756
|
+
else 0
|
|
757
|
+
)
|
|
758
|
+
if measure_ready and finish_attempts >= MAX_WAKE_ATTEMPTS:
|
|
759
|
+
log.warning(
|
|
760
|
+
"run %s: %d follow-ups failed to finish the landed re-measure; parked",
|
|
761
|
+
record.run_id,
|
|
762
|
+
finish_attempts,
|
|
763
|
+
)
|
|
764
|
+
continue
|
|
765
|
+
if record.wake_attempts >= MAX_WAKE_ATTEMPTS and not measure_ready:
|
|
687
766
|
log.warning(
|
|
688
767
|
"run %s: %d follow-up attempts without progress; not resubmitting",
|
|
689
768
|
record.run_id,
|
|
@@ -725,17 +804,14 @@ def service_in_review(
|
|
|
725
804
|
if panel_argv:
|
|
726
805
|
panel_argv = [*panel_argv, "--panel-minutes", str(job_minutes - author_minutes)]
|
|
727
806
|
argv = [
|
|
728
|
-
|
|
729
|
-
"run",
|
|
730
|
-
"python",
|
|
807
|
+
*_interpreter(spec.home),
|
|
731
808
|
"-m",
|
|
732
809
|
"outerloop.followup",
|
|
733
810
|
"--run-root",
|
|
734
811
|
str(spec.run_root),
|
|
735
812
|
"--run-id",
|
|
736
813
|
record.run_id,
|
|
737
|
-
|
|
738
|
-
spec.image,
|
|
814
|
+
*_containment(spec.image),
|
|
739
815
|
"--bot-login",
|
|
740
816
|
spec.bot_login,
|
|
741
817
|
"--job-minutes",
|
|
@@ -777,11 +853,16 @@ def service_in_review(
|
|
|
777
853
|
# read-modify-write on the FRESH record: the submitted job may
|
|
778
854
|
# already be saving its own fields
|
|
779
855
|
latest = load_record(root, record.run_id)
|
|
856
|
+
stage = latest.followup_stage
|
|
857
|
+
if measure_ready and stage:
|
|
858
|
+
# one finishing attempt billed against this landed measure
|
|
859
|
+
stage = {**stage, "finish_attempts": finish_attempts + 1}
|
|
780
860
|
save_record(
|
|
781
861
|
root,
|
|
782
862
|
replace(
|
|
783
863
|
latest,
|
|
784
864
|
followup_job_id=job_id,
|
|
865
|
+
followup_stage=stage,
|
|
785
866
|
wake_attempts=latest.wake_attempts + 1,
|
|
786
867
|
),
|
|
787
868
|
now,
|
|
@@ -914,7 +995,7 @@ def dispatch_wake_armed(root: Path) -> bool:
|
|
|
914
995
|
sentinel file (touch/rm, no chain restart). Read by the tick and by every
|
|
915
996
|
park, so a disarm takes effect at once."""
|
|
916
997
|
return (
|
|
917
|
-
bool(os.environ.get("
|
|
998
|
+
bool(os.environ.get("OUTERLOOP_DISPATCH_WAKE", "").strip())
|
|
918
999
|
or (root / DISPATCH_WAKE_SENTINEL).exists()
|
|
919
1000
|
)
|
|
920
1001
|
|
|
@@ -1565,6 +1646,32 @@ def _sweep_one(
|
|
|
1565
1646
|
if now - record.terminal_seen >= grace_s:
|
|
1566
1647
|
wake(record, f"experiment {state}", state)
|
|
1567
1648
|
elif any(is_pending(s) for s in states) and record.deadline > 0 and now > record.deadline:
|
|
1649
|
+
# Past the deadline with a job still queued. Ask Slurm WHY before
|
|
1650
|
+
# calling it unschedulable: a busy queue or the account's own cap is
|
|
1651
|
+
# a wait a scientist would sit out, so the deadline moves out by one
|
|
1652
|
+
# slack window instead (Torch 2026-09-06: four launches pending on
|
|
1653
|
+
# QOSMaxGRESPerUser were about to be cancelled and re-launched into
|
|
1654
|
+
# the same cap). Local compute has no queue and no reasons.
|
|
1655
|
+
pending = [j for j, s in zip(job_ids, states, strict=True) if is_pending(s)]
|
|
1656
|
+
reason_of = getattr(compute, "pending_reason", None)
|
|
1657
|
+
reasons: dict[str, str] = {}
|
|
1658
|
+
if reason_of is not None:
|
|
1659
|
+
try:
|
|
1660
|
+
reasons = {j: str(reason_of(j)) for j in pending}
|
|
1661
|
+
except SlurmQueryError:
|
|
1662
|
+
deferred.append(record.run_id) # unknown is never "cancel"
|
|
1663
|
+
return
|
|
1664
|
+
if reasons and all(is_queue_wait(r) for r in reasons.values()):
|
|
1665
|
+
extended = now + BLIND_PARK_SLACK_MIN * 60
|
|
1666
|
+
if not dry_run:
|
|
1667
|
+
save_record(root, replace(record, deadline=extended), now)
|
|
1668
|
+
log.info(
|
|
1669
|
+
"sweep: %s waits in the queue (%s); deadline extended by %d min",
|
|
1670
|
+
record.run_id,
|
|
1671
|
+
", ".join(f"{j}={r}" for j, r in reasons.items()),
|
|
1672
|
+
BLIND_PARK_SLACK_MIN,
|
|
1673
|
+
)
|
|
1674
|
+
return
|
|
1568
1675
|
# Unschedulable in practice: cancel every non-terminal job
|
|
1569
1676
|
# (best-effort — scancel trouble must not abort the sweep), then
|
|
1570
1677
|
# wake with that fact.
|
|
@@ -1756,6 +1863,12 @@ def tick(
|
|
|
1756
1863
|
allow_submit=launch_ok,
|
|
1757
1864
|
contract=contract,
|
|
1758
1865
|
)
|
|
1866
|
+
# ended runs are known now, so a held launch of an ended run is dropped
|
|
1867
|
+
# and a GPU slot freed by a finished eval is handed to the oldest held one
|
|
1868
|
+
try:
|
|
1869
|
+
service_admission(root, compute, spec, dry_run=followup_dry_run)
|
|
1870
|
+
except Exception as exc: # admission is advisory to the tick
|
|
1871
|
+
log.warning("admission service failed: %s", exc)
|
|
1759
1872
|
try:
|
|
1760
1873
|
service_research_log(root, github, spec, now)
|
|
1761
1874
|
except Exception as exc: # the ledger is advisory; the tick continues
|
|
@@ -1790,7 +1903,7 @@ def tick(
|
|
|
1790
1903
|
log.warning("self-initiated selection failed: %s", exc)
|
|
1791
1904
|
# AFTER the launch block: a run started this tick is on the strip
|
|
1792
1905
|
# this tick, not the next one
|
|
1793
|
-
service_boards(root, github, spec.target, contract, now)
|
|
1906
|
+
service_boards(root, github, spec.target, contract, now, compute)
|
|
1794
1907
|
report = replace_report(
|
|
1795
1908
|
report,
|
|
1796
1909
|
ended,
|
|
@@ -1807,6 +1920,120 @@ def tick(
|
|
|
1807
1920
|
return report
|
|
1808
1921
|
|
|
1809
1922
|
|
|
1923
|
+
LAUNCH_MARK = "-launch-" # every author launch job is named <run_id>-launch-<name>
|
|
1924
|
+
|
|
1925
|
+
|
|
1926
|
+
@dataclass(frozen=True)
|
|
1927
|
+
class AdmissionReport:
|
|
1928
|
+
released: tuple[str, ...] = () # job ids let into the queue this tick
|
|
1929
|
+
dropped: tuple[str, ...] = () # held launches of runs that already ended
|
|
1930
|
+
held: int = 0 # still waiting after this tick
|
|
1931
|
+
gpus_in_use: int = 0 # GPUs held by this user's eligible jobs, after releases
|
|
1932
|
+
|
|
1933
|
+
|
|
1934
|
+
def _cap_reason(reason: str) -> bool:
|
|
1935
|
+
head = reason.strip().split(",")[0].split(" ")[0]
|
|
1936
|
+
return any(tag in head for tag in ("PerUser", "PerAccount", "Grp")) and "PerJob" not in head
|
|
1937
|
+
|
|
1938
|
+
|
|
1939
|
+
def service_admission(
|
|
1940
|
+
root: Path, compute: Compute, spec: FollowupSpec, *, dry_run: bool = False
|
|
1941
|
+
) -> AdmissionReport:
|
|
1942
|
+
"""Launch admission (Torch 2026-09-06: six 8-GPU launches queued against a
|
|
1943
|
+
16-GPU per-user cap, four of them pending for a day on QOSMaxGRESPerUser).
|
|
1944
|
+
Author launches are submitted HELD when a cap is configured; every tick
|
|
1945
|
+
this releases them oldest-first while the user's eligible GPU jobs (evals
|
|
1946
|
+
and launches alike) fit under the cap, and cancels held launches whose run
|
|
1947
|
+
has already ended. A released launch that Slurm then parks on a per-user
|
|
1948
|
+
reason means the configured cap is too high: nothing more is released
|
|
1949
|
+
that tick. Off (cap 0) and in local mode this does nothing. A failed
|
|
1950
|
+
queue read releases nothing (blind is never "free")."""
|
|
1951
|
+
if local_mode():
|
|
1952
|
+
return AdmissionReport() # local jobs are synchronous; nothing is ever held
|
|
1953
|
+
try:
|
|
1954
|
+
rows = compute.queue_snapshot()
|
|
1955
|
+
except SlurmQueryError as exc:
|
|
1956
|
+
log.warning("admission: cannot read the queue (%s); releasing nothing", exc)
|
|
1957
|
+
return AdmissionReport()
|
|
1958
|
+
# Only the kernel's own LAUNCH jobs are ever released or cancelled here,
|
|
1959
|
+
# attributed by JOB ID through each record's launch ids (a candidate park's
|
|
1960
|
+
# afterany also carries the gate's evals, which are never launches). Names
|
|
1961
|
+
# are never parsed: they are cut to 60 characters at submission, and
|
|
1962
|
+
# `squeue --me` lists every job of the Unix user, ours or not. A held job
|
|
1963
|
+
# no record claims is left exactly as it is.
|
|
1964
|
+
from outerloop.attempt import _stage_launch_job_ids
|
|
1965
|
+
|
|
1966
|
+
owner_state: dict[str, str] = {}
|
|
1967
|
+
for record in list_runs(root):
|
|
1968
|
+
for jid in _stage_launch_job_ids(record):
|
|
1969
|
+
owner_state[jid] = record.state
|
|
1970
|
+
held: list[dict[str, str]] = []
|
|
1971
|
+
in_use = 0
|
|
1972
|
+
at_cap = False
|
|
1973
|
+
dropped: list[str] = []
|
|
1974
|
+
for row in rows:
|
|
1975
|
+
state, reason, jid = row.get("state", ""), row.get("reason", ""), row.get("id", "")
|
|
1976
|
+
gpus = gpus_in_gres(row.get("gres", ""))
|
|
1977
|
+
owner = owner_state.get(jid)
|
|
1978
|
+
if is_pending(state) and reason.startswith("JobHeldUser"):
|
|
1979
|
+
if owner is None:
|
|
1980
|
+
continue # not ours, or not a launch: never touched
|
|
1981
|
+
if owner != WAITING:
|
|
1982
|
+
# the run ended (PR landed, abandoned, stuck) before its
|
|
1983
|
+
# launch ever ran: nothing will read the result
|
|
1984
|
+
if not dry_run:
|
|
1985
|
+
with contextlib.suppress(Exception):
|
|
1986
|
+
compute.cancel(jid)
|
|
1987
|
+
dropped.append(jid)
|
|
1988
|
+
continue
|
|
1989
|
+
held.append(row)
|
|
1990
|
+
continue
|
|
1991
|
+
# every GPU job that is not finished counts against the cap, including
|
|
1992
|
+
# one still COMPLETING: Slurm holds its allocation until it exits
|
|
1993
|
+
if gpus and not is_terminal(state) and state != GONE:
|
|
1994
|
+
in_use += gpus
|
|
1995
|
+
if is_pending(state) and owner is not None and _cap_reason(reason):
|
|
1996
|
+
at_cap = True
|
|
1997
|
+
held.sort(key=lambda r: int(r["id"]) if r["id"].isdigit() else 0)
|
|
1998
|
+
released: list[str] = []
|
|
1999
|
+
if spec.max_launch_gpus <= 0:
|
|
2000
|
+
# admission switched off after these were submitted held: let every
|
|
2001
|
+
# claimed launch go, or they would wait as JobHeldUser forever
|
|
2002
|
+
for row in held:
|
|
2003
|
+
if not dry_run:
|
|
2004
|
+
compute.release(row["id"])
|
|
2005
|
+
released.append(row["id"])
|
|
2006
|
+
elif at_cap:
|
|
2007
|
+
log.info(
|
|
2008
|
+
"admission: a released launch waits on a per-user cap; %d held launch(es) stay held",
|
|
2009
|
+
len(held),
|
|
2010
|
+
)
|
|
2011
|
+
else:
|
|
2012
|
+
for row in held:
|
|
2013
|
+
gpus = gpus_in_gres(row.get("gres", ""))
|
|
2014
|
+
if in_use + gpus > spec.max_launch_gpus:
|
|
2015
|
+
break
|
|
2016
|
+
if not dry_run:
|
|
2017
|
+
compute.release(row["id"])
|
|
2018
|
+
released.append(row["id"])
|
|
2019
|
+
in_use += gpus
|
|
2020
|
+
if released or dropped:
|
|
2021
|
+
log.info(
|
|
2022
|
+
"admission: released %s; dropped %s; %d still held; %d/%d GPUs in use",
|
|
2023
|
+
",".join(released) or "-",
|
|
2024
|
+
",".join(dropped) or "-",
|
|
2025
|
+
len(held) - len(released),
|
|
2026
|
+
in_use,
|
|
2027
|
+
spec.max_launch_gpus,
|
|
2028
|
+
)
|
|
2029
|
+
return AdmissionReport(
|
|
2030
|
+
released=tuple(released),
|
|
2031
|
+
dropped=tuple(dropped),
|
|
2032
|
+
held=len(held) - len(released),
|
|
2033
|
+
gpus_in_use=in_use,
|
|
2034
|
+
)
|
|
2035
|
+
|
|
2036
|
+
|
|
1810
2037
|
def service_syncs(root: Path, spec: Any, now: float) -> None:
|
|
1811
2038
|
"""Honor mid-leg sync requests: a LIVE session asked for fresh origin/*
|
|
1812
2039
|
refs and is waiting inside its own clock. The fetch pins the canonical
|
|
@@ -1844,10 +2071,13 @@ def service_syncs(root: Path, spec: Any, now: float) -> None:
|
|
|
1844
2071
|
log.warning("sync failed for %s: %s", record.run_id, exc)
|
|
1845
2072
|
|
|
1846
2073
|
|
|
1847
|
-
def service_boards(
|
|
2074
|
+
def service_boards(
|
|
2075
|
+
root: Path, github: Any, target: str, contract: Any, now: float, compute: Any = None
|
|
2076
|
+
) -> None:
|
|
1848
2077
|
"""The climb board and the live strip, together and advisory: the views
|
|
1849
2078
|
publish from the first tick (before any run ends), and a failure never
|
|
1850
|
-
stops the tick.
|
|
2079
|
+
stops the tick. With a compute backend, the strip also carries the
|
|
2080
|
+
kernel's queue (its own Slurm jobs, attributed to agents)."""
|
|
1851
2081
|
try:
|
|
1852
2082
|
from outerloop.climbboard import contract_directions, service_climb_board
|
|
1853
2083
|
|
|
@@ -1857,7 +2087,13 @@ def service_boards(root: Path, github: Any, target: str, contract: Any, now: flo
|
|
|
1857
2087
|
try:
|
|
1858
2088
|
from outerloop.climbboard import service_status
|
|
1859
2089
|
|
|
1860
|
-
|
|
2090
|
+
queue = None
|
|
2091
|
+
if compute is not None:
|
|
2092
|
+
try:
|
|
2093
|
+
queue = compute.queue_snapshot()
|
|
2094
|
+
except Exception as exc: # blind this tick: the strip publishes without a queue
|
|
2095
|
+
log.warning("queue snapshot failed: %s", exc)
|
|
2096
|
+
service_status(root, github, target, now, contract, queue=queue)
|
|
1861
2097
|
except Exception as exc: # each is advisory ALONE: one failing never mutes the other
|
|
1862
2098
|
log.warning("status strip service failed: %s", exc)
|
|
1863
2099
|
|
|
@@ -2115,13 +2351,13 @@ def _climb_panel_argv(spec: FollowupSpec) -> list[str]:
|
|
|
2115
2351
|
def _author_config_error(spec: FollowupSpec) -> str:
|
|
2116
2352
|
"""Why the config-driven author would die at the climb's startup ("" when it
|
|
2117
2353
|
won't), checked on the tick host BEFORE a claim/submit so a codex misconfig
|
|
2118
|
-
(e.g.
|
|
2354
|
+
(e.g. OUTERLOOP_AUTHOR_BACKEND=codex with no non-claude model) never
|
|
2119
2355
|
strands a claimed intake issue. Reads the fleet author config from env — the
|
|
2120
2356
|
same source the climb defaults from — and the image the tick already knows."""
|
|
2121
2357
|
from outerloop.attempt import codex_author_config_error
|
|
2122
2358
|
|
|
2123
|
-
backend = os.environ.get("
|
|
2124
|
-
model = os.environ.get("
|
|
2359
|
+
backend = os.environ.get("OUTERLOOP_AUTHOR_BACKEND") or "claude"
|
|
2360
|
+
model = os.environ.get("OUTERLOOP_AUTHOR_MODEL") or "claude-opus-5"
|
|
2125
2361
|
return codex_author_config_error(backend, model, spec.image)
|
|
2126
2362
|
|
|
2127
2363
|
|
|
@@ -2152,8 +2388,8 @@ def _panel_preflight_error(spec: FollowupSpec) -> str:
|
|
|
2152
2388
|
# neither the author's nor the claude panel key + readable), and for
|
|
2153
2389
|
# hermes its pinned clone.
|
|
2154
2390
|
shelled = {
|
|
2155
|
-
"codex": "
|
|
2156
|
-
"hermes": "
|
|
2391
|
+
"codex": "OUTERLOOP_PANEL_CODEX_KEY_FILE",
|
|
2392
|
+
"hermes": "OUTERLOOP_PANEL_HERMES_KEY_FILE",
|
|
2157
2393
|
}
|
|
2158
2394
|
for lens_backend, key_env in shelled.items():
|
|
2159
2395
|
if not any(backend == lens_backend for _, backend, _ in lenses):
|
|
@@ -2161,7 +2397,7 @@ def _panel_preflight_error(spec: FollowupSpec) -> str:
|
|
|
2161
2397
|
if not spec.image or not Path(spec.image).is_file():
|
|
2162
2398
|
return (
|
|
2163
2399
|
f"a {lens_backend} panel lens requires a real container image "
|
|
2164
|
-
f"(
|
|
2400
|
+
f"(OUTERLOOP_IMAGE={spec.image!r})"
|
|
2165
2401
|
)
|
|
2166
2402
|
key_raw = os.environ.get(key_env, "").strip()
|
|
2167
2403
|
if not key_raw:
|
|
@@ -2217,7 +2453,7 @@ def _panel_preflight_error(spec: FollowupSpec) -> str:
|
|
|
2217
2453
|
# (claude vs codex keys coexist), config-driven like the climb itself — so
|
|
2218
2454
|
# the role-separation check compares the panel key against the RIGHT author
|
|
2219
2455
|
# key, and a codex run is never judged by a stray Claude key.
|
|
2220
|
-
fleet_backend = os.environ.get("
|
|
2456
|
+
fleet_backend = os.environ.get("OUTERLOOP_AUTHOR_BACKEND") or "claude"
|
|
2221
2457
|
author = Path(resolve_author_key_file(fleet_backend))
|
|
2222
2458
|
if not author.is_absolute():
|
|
2223
2459
|
# same rule as the panel key: the climb resolves paths from a
|
|
@@ -2406,7 +2642,7 @@ def service_self_initiated(
|
|
|
2406
2642
|
# that would publish panel-less self-merging PRs (terra #171)
|
|
2407
2643
|
log.error(
|
|
2408
2644
|
"attempt on %s not launched: contract sets merge:auto but "
|
|
2409
|
-
"no panel is configured (set
|
|
2645
|
+
"no panel is configured (set OUTERLOOP_PANEL, or the "
|
|
2410
2646
|
"contract back to merge:manual)",
|
|
2411
2647
|
benchmark,
|
|
2412
2648
|
)
|
|
@@ -2418,7 +2654,7 @@ def service_self_initiated(
|
|
|
2418
2654
|
if author_error:
|
|
2419
2655
|
log.error(
|
|
2420
2656
|
"climb on %s not launched: author misconfigured — %s "
|
|
2421
|
-
"(fix
|
|
2657
|
+
"(fix OUTERLOOP_AUTHOR_BACKEND/_MODEL)",
|
|
2422
2658
|
benchmark,
|
|
2423
2659
|
author_error,
|
|
2424
2660
|
)
|
|
@@ -2427,7 +2663,7 @@ def service_self_initiated(
|
|
|
2427
2663
|
if panel_error:
|
|
2428
2664
|
log.error(
|
|
2429
2665
|
"climb on %s not launched: panel misconfigured — %s "
|
|
2430
|
-
"(fix it, or set
|
|
2666
|
+
"(fix it, or set OUTERLOOP_PANEL='' to disable the panel)",
|
|
2431
2667
|
benchmark,
|
|
2432
2668
|
panel_error,
|
|
2433
2669
|
)
|
|
@@ -2436,9 +2672,7 @@ def service_self_initiated(
|
|
|
2436
2672
|
return (benchmark, "dry-run")
|
|
2437
2673
|
job_minutes = _attempt_job_minutes(spec, limits)
|
|
2438
2674
|
argv = [
|
|
2439
|
-
|
|
2440
|
-
"run",
|
|
2441
|
-
"python",
|
|
2675
|
+
*_interpreter(spec.home),
|
|
2442
2676
|
"-m",
|
|
2443
2677
|
"outerloop.attempt",
|
|
2444
2678
|
"--target",
|
|
@@ -2447,8 +2681,7 @@ def service_self_initiated(
|
|
|
2447
2681
|
benchmark,
|
|
2448
2682
|
"--run-root",
|
|
2449
2683
|
str(spec.run_root),
|
|
2450
|
-
|
|
2451
|
-
spec.image,
|
|
2684
|
+
*_containment(spec.image),
|
|
2452
2685
|
"--agent-id",
|
|
2453
2686
|
slot_agent,
|
|
2454
2687
|
*_climb_limit_argv(limits, job_minutes),
|
|
@@ -2457,7 +2690,7 @@ def service_self_initiated(
|
|
|
2457
2690
|
if spec.pat_file:
|
|
2458
2691
|
argv += ["--pat-file", spec.pat_file]
|
|
2459
2692
|
# config-driven author: climb resolves the author backend/model/key from
|
|
2460
|
-
#
|
|
2693
|
+
# OUTERLOOP_AUTHOR_* env (inherited by the job), so the tick threads
|
|
2461
2694
|
# neither the backend nor its key — a new backend needs zero tick change.
|
|
2462
2695
|
job_id = compute.submit(
|
|
2463
2696
|
JobSpec(
|
|
@@ -2565,9 +2798,7 @@ def service_steward(
|
|
|
2565
2798
|
|
|
2566
2799
|
work_order_b64 = _b64.b64encode(issue_hypothesis(task).encode()).decode()
|
|
2567
2800
|
argv = [
|
|
2568
|
-
|
|
2569
|
-
"run",
|
|
2570
|
-
"python",
|
|
2801
|
+
*_interpreter(spec.home),
|
|
2571
2802
|
"-m",
|
|
2572
2803
|
"outerloop.steward",
|
|
2573
2804
|
"--target",
|
|
@@ -2576,8 +2807,7 @@ def service_steward(
|
|
|
2576
2807
|
task.benchmark,
|
|
2577
2808
|
"--run-root",
|
|
2578
2809
|
str(spec.run_root),
|
|
2579
|
-
|
|
2580
|
-
spec.image,
|
|
2810
|
+
*_containment(spec.image),
|
|
2581
2811
|
"--issue",
|
|
2582
2812
|
str(task.number),
|
|
2583
2813
|
"--work-order-b64",
|
|
@@ -2664,7 +2894,7 @@ def service_intake(
|
|
|
2664
2894
|
# that would publish panel-less self-merging PRs (terra #171)
|
|
2665
2895
|
log.error(
|
|
2666
2896
|
"attempt on %s not launched: contract sets merge:auto but "
|
|
2667
|
-
"no panel is configured (set
|
|
2897
|
+
"no panel is configured (set OUTERLOOP_PANEL, or the "
|
|
2668
2898
|
"contract back to merge:manual)",
|
|
2669
2899
|
task.benchmark,
|
|
2670
2900
|
)
|
|
@@ -2676,7 +2906,7 @@ def service_intake(
|
|
|
2676
2906
|
if author_error:
|
|
2677
2907
|
log.error(
|
|
2678
2908
|
"issue #%d not claimed: author misconfigured — %s "
|
|
2679
|
-
"(fix
|
|
2909
|
+
"(fix OUTERLOOP_AUTHOR_BACKEND/_MODEL)",
|
|
2680
2910
|
task.number,
|
|
2681
2911
|
author_error,
|
|
2682
2912
|
)
|
|
@@ -2685,7 +2915,7 @@ def service_intake(
|
|
|
2685
2915
|
if panel_error:
|
|
2686
2916
|
log.error(
|
|
2687
2917
|
"issue #%d not claimed: panel misconfigured — %s "
|
|
2688
|
-
"(fix it, or set
|
|
2918
|
+
"(fix it, or set OUTERLOOP_PANEL='' to disable the panel)",
|
|
2689
2919
|
task.number,
|
|
2690
2920
|
panel_error,
|
|
2691
2921
|
)
|
|
@@ -2707,9 +2937,7 @@ def service_intake(
|
|
|
2707
2937
|
|
|
2708
2938
|
hypothesis_b64 = _b64.b64encode(issue_hypothesis(task).encode()).decode()
|
|
2709
2939
|
argv = [
|
|
2710
|
-
|
|
2711
|
-
"run",
|
|
2712
|
-
"python",
|
|
2940
|
+
*_interpreter(spec.home),
|
|
2713
2941
|
"-m",
|
|
2714
2942
|
"outerloop.attempt",
|
|
2715
2943
|
"--target",
|
|
@@ -2718,8 +2946,7 @@ def service_intake(
|
|
|
2718
2946
|
task.benchmark,
|
|
2719
2947
|
"--run-root",
|
|
2720
2948
|
str(spec.run_root),
|
|
2721
|
-
|
|
2722
|
-
spec.image,
|
|
2949
|
+
*_containment(spec.image),
|
|
2723
2950
|
"--issue",
|
|
2724
2951
|
str(task.number),
|
|
2725
2952
|
"--hypothesis-b64",
|
|
@@ -2730,7 +2957,7 @@ def service_intake(
|
|
|
2730
2957
|
if spec.pat_file:
|
|
2731
2958
|
argv += ["--pat-file", spec.pat_file]
|
|
2732
2959
|
# config-driven author: climb resolves the author key from the
|
|
2733
|
-
#
|
|
2960
|
+
# OUTERLOOP_AUTHOR_* env by backend; the tick does not thread it.
|
|
2734
2961
|
try:
|
|
2735
2962
|
job_id = compute.submit(
|
|
2736
2963
|
JobSpec(
|
|
@@ -2791,17 +3018,14 @@ class JobWakeDispatcher:
|
|
|
2791
3018
|
|
|
2792
3019
|
def dispatch(self, record: RunRecord, reason: str) -> str:
|
|
2793
3020
|
argv = [
|
|
2794
|
-
|
|
2795
|
-
"run",
|
|
2796
|
-
"python",
|
|
3021
|
+
*_interpreter(self.spec.home),
|
|
2797
3022
|
"-m",
|
|
2798
3023
|
"outerloop.attempt",
|
|
2799
3024
|
"--resume",
|
|
2800
3025
|
record.run_id,
|
|
2801
3026
|
"--run-root",
|
|
2802
3027
|
str(self.spec.run_root),
|
|
2803
|
-
|
|
2804
|
-
self.spec.image,
|
|
3028
|
+
*_containment(self.spec.image),
|
|
2805
3029
|
"--account",
|
|
2806
3030
|
self.spec.account,
|
|
2807
3031
|
"--partition",
|
|
@@ -2877,7 +3101,7 @@ def _wake_dispatcher_from_env(
|
|
|
2877
3101
|
"""The wake delivery for this tick, behind an EXPLICIT on-switch so the
|
|
2878
3102
|
dispatched-wake path lands DARK. Returns `(dispatcher, live)`:
|
|
2879
3103
|
|
|
2880
|
-
* armed (the `
|
|
3104
|
+
* armed (the `OUTERLOOP_DISPATCH_WAKE` env var OR a `<root>/DISPATCH_WAKE`
|
|
2881
3105
|
sentinel file) AND the chain env carries what a wake job needs -> the real
|
|
2882
3106
|
`JobWakeDispatcher` and a LIVE sweep;
|
|
2883
3107
|
* otherwise -> the `LoggingDispatcher` and a DRY sweep.
|
|
@@ -2895,8 +3119,23 @@ def _wake_dispatcher_from_env(
|
|
|
2895
3119
|
return JobWakeDispatcher(compute, followup_spec, now), True
|
|
2896
3120
|
|
|
2897
3121
|
|
|
3122
|
+
def max_launch_gpus_from_env() -> int:
|
|
3123
|
+
"""OUTERLOOP_MAX_LAUNCH_GPUS: the per-user GPU cap launch admission keeps
|
|
3124
|
+
the fleet under; unset, empty or 0 = admission off. Read by the tick (to
|
|
3125
|
+
release) and by the attempt (to submit held), so the two agree."""
|
|
3126
|
+
raw = os.environ.get("OUTERLOOP_MAX_LAUNCH_GPUS", "").strip()
|
|
3127
|
+
if not raw:
|
|
3128
|
+
return 0
|
|
3129
|
+
try:
|
|
3130
|
+
value = int(raw)
|
|
3131
|
+
except ValueError:
|
|
3132
|
+
log.warning("OUTERLOOP_MAX_LAUNCH_GPUS=%r is not an integer; admission off", raw)
|
|
3133
|
+
return 0
|
|
3134
|
+
return max(value, 0)
|
|
3135
|
+
|
|
3136
|
+
|
|
2898
3137
|
def _max_job_minutes_from_env() -> int:
|
|
2899
|
-
"""
|
|
3138
|
+
"""OUTERLOOP_MAX_JOB_MINUTES, clamped into what the code can honor:
|
|
2900
3139
|
at least the climb-job floor (an operator on a short-MaxTime partition
|
|
2901
3140
|
must be able to LOWER the cap below cpu_short's 6h, or every submit is
|
|
2902
3141
|
rejected), at most the ceiling the stranded window allows. A clamped
|
|
@@ -2904,24 +3143,24 @@ def _max_job_minutes_from_env() -> int:
|
|
|
2904
3143
|
rejecting jobs for no reason."""
|
|
2905
3144
|
from outerloop.limits import ATTEMPT_JOB_MINUTES_FLOOR
|
|
2906
3145
|
|
|
2907
|
-
raw = os.environ.get("
|
|
3146
|
+
raw = os.environ.get("OUTERLOOP_MAX_JOB_MINUTES", "").strip()
|
|
2908
3147
|
if not raw:
|
|
2909
3148
|
return MAX_ATTEMPT_JOB_MINUTES
|
|
2910
3149
|
try:
|
|
2911
3150
|
value = int(raw)
|
|
2912
3151
|
except ValueError:
|
|
2913
|
-
log.warning("
|
|
3152
|
+
log.warning("OUTERLOOP_MAX_JOB_MINUTES=%r is not an integer; using default", raw)
|
|
2914
3153
|
return MAX_ATTEMPT_JOB_MINUTES
|
|
2915
3154
|
clamped = max(ATTEMPT_JOB_MINUTES_FLOOR, min(value, MAX_JOB_MINUTES_CEILING))
|
|
2916
3155
|
if clamped != value:
|
|
2917
|
-
log.warning("
|
|
3156
|
+
log.warning("OUTERLOOP_MAX_JOB_MINUTES=%d clamped to %d", value, clamped)
|
|
2918
3157
|
return clamped
|
|
2919
3158
|
|
|
2920
3159
|
|
|
2921
3160
|
def _cadence_s() -> float:
|
|
2922
|
-
"""The chain's tick cadence in seconds (
|
|
3161
|
+
"""The chain's tick cadence in seconds (OUTERLOOP_CADENCE_MIN, the same
|
|
2923
3162
|
knob tick_chain.sbatch uses), defaulting to 30 min when unset/invalid."""
|
|
2924
|
-
raw = os.environ.get("
|
|
3163
|
+
raw = os.environ.get("OUTERLOOP_CADENCE_MIN", "").strip()
|
|
2925
3164
|
try:
|
|
2926
3165
|
cadence_s = float(raw) * 60 if raw else 30 * 60
|
|
2927
3166
|
except ValueError:
|
|
@@ -2931,7 +3170,7 @@ def _cadence_s() -> float:
|
|
|
2931
3170
|
|
|
2932
3171
|
def _coalesce_ceiling_s() -> float:
|
|
2933
3172
|
"""The largest SAFE coalesce window, bounding both the default and an
|
|
2934
|
-
explicit
|
|
3173
|
+
explicit OUTERLOOP_MIN_TICK_MINUTES: half the cadence (so an on-cadence
|
|
2935
3174
|
tick is never coalesced even when the previous one ran a little late), and
|
|
2936
3175
|
never above the absolute MAX_MIN_TICK_S. A window at/above the cadence would
|
|
2937
3176
|
swallow every normal tick and stall the loop — this is what forbids it."""
|
|
@@ -2947,29 +3186,29 @@ def _default_min_tick_s() -> float:
|
|
|
2947
3186
|
|
|
2948
3187
|
|
|
2949
3188
|
def _min_tick_s_from_env() -> float:
|
|
2950
|
-
"""
|
|
3189
|
+
"""OUTERLOOP_MIN_TICK_MINUTES -> the coalesce window in seconds. Unset
|
|
2951
3190
|
derives a cadence-aware default; non-numeric/non-finite also fall back to it;
|
|
2952
3191
|
negative clamps to 0 (coalesce disabled); a value at/above the safe ceiling
|
|
2953
3192
|
(half the cadence, capped at MAX_MIN_TICK_S) clamps down so it cannot stall
|
|
2954
3193
|
the loop."""
|
|
2955
|
-
raw = os.environ.get("
|
|
3194
|
+
raw = os.environ.get("OUTERLOOP_MIN_TICK_MINUTES", "").strip()
|
|
2956
3195
|
if not raw:
|
|
2957
3196
|
return _default_min_tick_s()
|
|
2958
3197
|
try:
|
|
2959
3198
|
minutes = float(raw)
|
|
2960
3199
|
except ValueError:
|
|
2961
|
-
log.warning("
|
|
3200
|
+
log.warning("OUTERLOOP_MIN_TICK_MINUTES=%r is not a number; using default", raw)
|
|
2962
3201
|
return _default_min_tick_s()
|
|
2963
3202
|
# reject inf/nan: an infinite window would coalesce every future tick and
|
|
2964
3203
|
# freeze the loop (a finite elapsed time is always < inf)
|
|
2965
3204
|
if not math.isfinite(minutes):
|
|
2966
|
-
log.warning("
|
|
3205
|
+
log.warning("OUTERLOOP_MIN_TICK_MINUTES=%r is not finite; using default", raw)
|
|
2967
3206
|
return _default_min_tick_s()
|
|
2968
3207
|
seconds = max(0.0, minutes * 60)
|
|
2969
3208
|
ceiling = _coalesce_ceiling_s()
|
|
2970
3209
|
if seconds > ceiling:
|
|
2971
3210
|
log.warning(
|
|
2972
|
-
"
|
|
3211
|
+
"OUTERLOOP_MIN_TICK_MINUTES=%s exceeds the safe ceiling "
|
|
2973
3212
|
"(%.0f min, ~half the cadence); clamping so normal ticks are not coalesced",
|
|
2974
3213
|
raw,
|
|
2975
3214
|
ceiling / 60,
|
|
@@ -2978,23 +3217,66 @@ def _min_tick_s_from_env() -> float:
|
|
|
2978
3217
|
return seconds
|
|
2979
3218
|
|
|
2980
3219
|
|
|
3220
|
+
def _default_image() -> str:
|
|
3221
|
+
"""~/outerloop-images/agent-py312.sif, or the pre-rename ~/autoresearch-images
|
|
3222
|
+
path when only that one exists. The fallback is dropped in the release after
|
|
3223
|
+
0.1."""
|
|
3224
|
+
new = os.path.expanduser("~/outerloop-images/agent-py312.sif")
|
|
3225
|
+
old = os.path.expanduser("~/autoresearch-images/agent-py312.sif")
|
|
3226
|
+
return old if (not os.path.isfile(new) and os.path.isfile(old)) else new
|
|
3227
|
+
|
|
3228
|
+
|
|
2981
3229
|
def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
|
|
2982
3230
|
"""GitHub client + FollowupSpec from the chain environment, or Nones when
|
|
2983
3231
|
the environment is incomplete (the tick then runs without in-review
|
|
2984
3232
|
servicing, and logs what is absent)."""
|
|
2985
|
-
pat_file = os.environ.get("
|
|
2986
|
-
app_file = os.environ.get("
|
|
2987
|
-
account = os.environ.get("
|
|
2988
|
-
partition = os.environ.get("
|
|
2989
|
-
image = os.environ.get(
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
3233
|
+
pat_file = os.environ.get("OUTERLOOP_PAT_FILE", "")
|
|
3234
|
+
app_file = os.environ.get("OUTERLOOP_GITHUB_APP_FILE", "")
|
|
3235
|
+
account = os.environ.get("OUTERLOOP_ACCOUNT", "")
|
|
3236
|
+
partition = os.environ.get("OUTERLOOP_PARTITION", "")
|
|
3237
|
+
image = os.environ.get("OUTERLOOP_IMAGE", _default_image())
|
|
3238
|
+
home = os.environ.get("OUTERLOOP_HOME", "")
|
|
3239
|
+
# Account and partition are optional on Slurm: empty ones leave the billing
|
|
3240
|
+
# association and the partition to Slurm's defaults, as `start` already
|
|
3241
|
+
# does for the resident. Local compute has no placement at all.
|
|
3242
|
+
target = os.environ.get("OUTERLOOP_TARGET", "")
|
|
3243
|
+
if not os.environ.get("OUTERLOOP_BOT_LOGIN", "").strip():
|
|
3244
|
+
# the default is the lab's bot account; under any other credential
|
|
3245
|
+
# the kernel would not recognize its own comments and PRs (#298)
|
|
3246
|
+
from outerloop.github import DEFAULT_BOT_LOGIN
|
|
3247
|
+
|
|
3248
|
+
log.warning(
|
|
3249
|
+
"OUTERLOOP_BOT_LOGIN is not set; posting as the default %r. Set it to the "
|
|
3250
|
+
"login the kernel posts as (init records it) unless that is your account.",
|
|
3251
|
+
DEFAULT_BOT_LOGIN,
|
|
3252
|
+
)
|
|
3253
|
+
image_ok = Path(image).is_file()
|
|
3254
|
+
panel = os.environ.get("OUTERLOOP_PANEL", "verify,review")
|
|
3255
|
+
if not image_ok and local_mode():
|
|
3256
|
+
# The local loop on a machine with no container: sessions run under
|
|
3257
|
+
# the harness's own sandbox and evaluations run bare, on the
|
|
3258
|
+
# operator's own machine with the operator's own keys. The panel is
|
|
3259
|
+
# off unless the operator opts in, because an uncontained judge holds
|
|
3260
|
+
# a shell next to its own key file. Said once per process. Contained
|
|
3261
|
+
# local mode needs apptainer and the image (docs/install.md).
|
|
3262
|
+
global _UNCONTAINED_WARNED
|
|
3263
|
+
if not _UNCONTAINED_WARNED:
|
|
3264
|
+
_UNCONTAINED_WARNED = True
|
|
3265
|
+
log.warning(
|
|
3266
|
+
"local mode: no container image at %s; sessions run under the harness "
|
|
3267
|
+
"sandbox and evaluations run bare on this machine; the panel is %s; a "
|
|
3268
|
+
"codex author needs the image (docs/install.md, local mode)",
|
|
3269
|
+
image,
|
|
3270
|
+
"on by OUTERLOOP_PANEL_UNCONTAINED=1"
|
|
3271
|
+
if os.environ.get("OUTERLOOP_PANEL_UNCONTAINED") == "1"
|
|
3272
|
+
else "off (OUTERLOOP_PANEL_UNCONTAINED=1 turns it on)",
|
|
3273
|
+
)
|
|
3274
|
+
if os.environ.get("OUTERLOOP_PANEL_UNCONTAINED") != "1":
|
|
3275
|
+
panel = ""
|
|
3276
|
+
# the jobs must not inherit a path to an image that is not there
|
|
3277
|
+
os.environ.pop("OUTERLOOP_IMAGE", None)
|
|
3278
|
+
image, image_ok = "", True
|
|
3279
|
+
if (pat_file or app_file) and home and target and image_ok:
|
|
2998
3280
|
from outerloop.appauth import resolve_bot_auth
|
|
2999
3281
|
from outerloop.github import GitHubClient
|
|
3000
3282
|
|
|
@@ -3008,16 +3290,15 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
|
|
|
3008
3290
|
home=Path(home),
|
|
3009
3291
|
pat_file=pat_file,
|
|
3010
3292
|
github_app_file=app_file,
|
|
3011
|
-
target=
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
gpu_partition=os.environ.get("AUTORESEARCH_GPU_PARTITION", ""),
|
|
3019
|
-
gpu_account=os.environ.get("AUTORESEARCH_GPU_ACCOUNT", ""),
|
|
3293
|
+
target=target,
|
|
3294
|
+
steward_key_file=os.environ.get("OUTERLOOP_STEWARD_KEY_FILE", ""),
|
|
3295
|
+
panel=panel,
|
|
3296
|
+
panel_key_file=os.environ.get("OUTERLOOP_PANEL_KEY_FILE", ""),
|
|
3297
|
+
job_partition=os.environ.get("OUTERLOOP_JOB_PARTITION", ""),
|
|
3298
|
+
gpu_partition=os.environ.get("OUTERLOOP_GPU_PARTITION", ""),
|
|
3299
|
+
gpu_account=os.environ.get("OUTERLOOP_GPU_ACCOUNT", ""),
|
|
3020
3300
|
max_job_minutes=_max_job_minutes_from_env(),
|
|
3301
|
+
max_launch_gpus=max_launch_gpus_from_env(),
|
|
3021
3302
|
)
|
|
3022
3303
|
return github, followup_spec
|
|
3023
3304
|
except Exception as exc:
|
|
@@ -3026,14 +3307,13 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
|
|
|
3026
3307
|
absent = [
|
|
3027
3308
|
name
|
|
3028
3309
|
for name, value in [
|
|
3029
|
-
("
|
|
3030
|
-
("
|
|
3031
|
-
("
|
|
3032
|
-
("AUTORESEARCH_HOME", home),
|
|
3310
|
+
("OUTERLOOP_PAT_FILE or _GITHUB_APP_FILE", pat_file or app_file),
|
|
3311
|
+
("OUTERLOOP_HOME", home),
|
|
3312
|
+
("OUTERLOOP_TARGET", target),
|
|
3033
3313
|
]
|
|
3034
3314
|
if not value
|
|
3035
3315
|
]
|
|
3036
|
-
if not
|
|
3316
|
+
if not image_ok:
|
|
3037
3317
|
absent.append(f"image:{image}")
|
|
3038
3318
|
log.info("in-review servicing disabled (missing: %s)", ", ".join(absent))
|
|
3039
3319
|
return None, None
|
|
@@ -3042,7 +3322,7 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
|
|
|
3042
3322
|
def _loop_cadence_s(cadence_min: float) -> float:
|
|
3043
3323
|
"""The --loop sleep, clamped to [60s, 24h]: argparse accepts inf (which
|
|
3044
3324
|
would OverflowError out of time.sleep) and sub-minute values would spin.
|
|
3045
|
-
A non-positive argument defers to
|
|
3325
|
+
A non-positive argument defers to OUTERLOOP_CADENCE_MIN."""
|
|
3046
3326
|
return min(24 * 3600.0, max(60.0, cadence_min * 60 if cadence_min > 0 else _cadence_s()))
|
|
3047
3327
|
|
|
3048
3328
|
|
|
@@ -3050,15 +3330,30 @@ def main() -> int:
|
|
|
3050
3330
|
import argparse
|
|
3051
3331
|
import time
|
|
3052
3332
|
|
|
3053
|
-
parser = argparse.ArgumentParser(
|
|
3333
|
+
parser = argparse.ArgumentParser(
|
|
3334
|
+
prog="outerloop tick",
|
|
3335
|
+
description="One tick of the loop: service the open runs, launch new work, wake "
|
|
3336
|
+
"parked runs. The chain runs this every cadence; --loop does the same in the "
|
|
3337
|
+
"foreground.",
|
|
3338
|
+
)
|
|
3054
3339
|
parser.add_argument("--root", required=True, type=Path, help="state root on the shared FS")
|
|
3055
|
-
parser.add_argument(
|
|
3056
|
-
|
|
3340
|
+
parser.add_argument(
|
|
3341
|
+
"--grace-s",
|
|
3342
|
+
type=float,
|
|
3343
|
+
default=DEFAULT_GRACE_S,
|
|
3344
|
+
help="seconds a finished experiment's delivery job gets before the sweep steps in",
|
|
3345
|
+
)
|
|
3346
|
+
parser.add_argument(
|
|
3347
|
+
"--lease-ttl-s",
|
|
3348
|
+
type=float,
|
|
3349
|
+
default=DEFAULT_LEASE_TTL_S,
|
|
3350
|
+
help="seconds after which a held run lease counts as stale",
|
|
3351
|
+
)
|
|
3057
3352
|
parser.add_argument(
|
|
3058
3353
|
"--min-free-gb",
|
|
3059
3354
|
type=float,
|
|
3060
3355
|
default=DEFAULT_MIN_FREE_BYTES / 1024**3,
|
|
3061
|
-
help="skip launching new work when the state filesystem has less free",
|
|
3356
|
+
help="skip launching new work when the state filesystem has less than this many GB free",
|
|
3062
3357
|
)
|
|
3063
3358
|
parser.add_argument(
|
|
3064
3359
|
"--loop",
|
|
@@ -3071,23 +3366,23 @@ def main() -> int:
|
|
|
3071
3366
|
type=float,
|
|
3072
3367
|
default=0.0,
|
|
3073
3368
|
help="minutes between --loop ticks; unset defers to "
|
|
3074
|
-
"
|
|
3369
|
+
"OUTERLOOP_CADENCE_MIN via the chain's own parser (default 30)",
|
|
3075
3370
|
)
|
|
3076
3371
|
args = parser.parse_args()
|
|
3077
3372
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
|
|
3078
3373
|
|
|
3079
3374
|
args.root.mkdir(parents=True, exist_ok=True)
|
|
3080
3375
|
# The tick's --root is the authority; children (and this process's own
|
|
3081
|
-
# LocalCompute) read
|
|
3376
|
+
# LocalCompute) read OUTERLOOP_ROOT, so a bare `tick --loop --root X`
|
|
3082
3377
|
# must not split-brain them: local job states would land nowhere and
|
|
3083
3378
|
# every finished job would read GONE until the park deadline.
|
|
3084
3379
|
# RESOLVED: local jobs cd into flight checkouts, so a relative root
|
|
3085
3380
|
# would scatter their state dirs across working directories
|
|
3086
|
-
if os.environ.get("
|
|
3087
|
-
os.environ["
|
|
3381
|
+
if os.environ.get("OUTERLOOP_ROOT", "") != str(args.root.resolve()):
|
|
3382
|
+
os.environ["OUTERLOOP_ROOT"] = str(args.root.resolve())
|
|
3088
3383
|
# In-review servicing is LIVE when credentials + image are available in the
|
|
3089
3384
|
# chain environment. The waiting-run sweep delivers real wakes only when the
|
|
3090
|
-
# operator arms it — the
|
|
3385
|
+
# operator arms it — the OUTERLOOP_DISPATCH_WAKE env var or a
|
|
3091
3386
|
# <root>/DISPATCH_WAKE sentinel — and the env is complete; by default it
|
|
3092
3387
|
# stays dry with the LoggingDispatcher — dispatched climbing lands DARK.
|
|
3093
3388
|
# ONE compute for the process: LocalCompute remembers its jobs' states
|