outerloop-science 0.1.0.dev2__py3-none-any.whl → 0.1.0.dev3__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 +2 -2
- outerloop/attempt.py +310 -93
- outerloop/brief.py +38 -25
- outerloop/cli.py +40 -5
- outerloop/climbboard.py +3 -0
- outerloop/compute.py +148 -53
- outerloop/contract.py +8 -0
- outerloop/dispatch.py +63 -18
- outerloop/evalcache.py +147 -0
- outerloop/followup.py +38 -16
- outerloop/github.py +38 -13
- outerloop/harness.py +1 -18
- outerloop/housekeeping.py +1 -17
- outerloop/image.py +0 -4
- outerloop/init.py +19 -1
- outerloop/intake.py +4 -7
- outerloop/launchlog.py +239 -0
- outerloop/maintain.py +325 -0
- outerloop/maintain_agent_cli.py +81 -0
- outerloop/maintain_post_cli.py +140 -0
- outerloop/measure.py +6 -0
- outerloop/orchestrator.py +141 -31
- outerloop/panel.py +3 -3
- outerloop/review.py +4 -0
- outerloop/review_agent.py +7 -7
- outerloop/review_agent_cli.py +2 -2
- outerloop/review_post_cli.py +2 -2
- outerloop/review_summarize_cli.py +7 -5
- outerloop/roles.py +27 -0
- outerloop/rolespec.py +3 -1
- outerloop/steward.py +5 -5
- outerloop/syscall.py +261 -47
- outerloop/syscall_cli.py +243 -12
- outerloop/tick.py +90 -178
- outerloop/verify_agent.py +8 -6
- outerloop/verify_post_cli.py +2 -2
- outerloop/watcher.py +203 -0
- {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev3.dist-info}/METADATA +4 -1
- outerloop_science-0.1.0.dev3.dist-info/RECORD +59 -0
- outerloop_science-0.1.0.dev2.dist-info/RECORD +0 -53
- {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev3.dist-info}/WHEEL +0 -0
- {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev3.dist-info}/entry_points.txt +0 -0
- {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev3.dist-info}/licenses/LICENSE +0 -0
- {outerloop_science-0.1.0.dev2.dist-info → outerloop_science-0.1.0.dev3.dist-info}/licenses/NOTICE +0 -0
outerloop/tick.py
CHANGED
|
@@ -36,7 +36,6 @@ from outerloop.compute import (
|
|
|
36
36
|
SlurmError,
|
|
37
37
|
SlurmQueryError,
|
|
38
38
|
compute_from_env,
|
|
39
|
-
gpus_in_gres,
|
|
40
39
|
is_pending,
|
|
41
40
|
is_terminal,
|
|
42
41
|
local_mode,
|
|
@@ -97,8 +96,7 @@ BLIND_PARK_SLACK_MIN = 12 * 60
|
|
|
97
96
|
# would put it at the back of the queue behind itself. Per-job limits
|
|
98
97
|
# (`...PerJob...`), a dependency that can never be satisfied, an invalid
|
|
99
98
|
# account/QOS or a held job are NOT waits: those never clear on their own.
|
|
100
|
-
# JobHeldUser is the
|
|
101
|
-
# held launch waits for a GPU slot, and an operator's hold is theirs to lift.
|
|
99
|
+
# JobHeldUser: an operator's hold is theirs to lift; the job is waiting, not stuck.
|
|
102
100
|
QUEUE_WAIT_REASONS = frozenset(
|
|
103
101
|
{"Priority", "Resources", "Reservation", "Dependency", "ReqNodeNotAvail", "JobHeldUser"}
|
|
104
102
|
)
|
|
@@ -148,18 +146,6 @@ class WakeDispatcher(Protocol):
|
|
|
148
146
|
def dispatch(self, record: RunRecord, reason: str) -> str: ...
|
|
149
147
|
|
|
150
148
|
|
|
151
|
-
@dataclass
|
|
152
|
-
class RecordingDispatcher:
|
|
153
|
-
"""Test/dry-run dispatcher: records what would have been woken."""
|
|
154
|
-
|
|
155
|
-
dispatched: list[tuple[str, str]] = field(default_factory=list)
|
|
156
|
-
holder_job_id: str = "" # set to simulate async dispatch
|
|
157
|
-
|
|
158
|
-
def dispatch(self, record: RunRecord, reason: str) -> str:
|
|
159
|
-
self.dispatched.append((record.run_id, reason))
|
|
160
|
-
return self.holder_job_id
|
|
161
|
-
|
|
162
|
-
|
|
163
149
|
@dataclass(frozen=True)
|
|
164
150
|
class TickReport:
|
|
165
151
|
paused: bool = False
|
|
@@ -244,11 +230,6 @@ class FollowupSpec:
|
|
|
244
230
|
# are minutes, work jobs can be hours, and Slurm prices walltime into
|
|
245
231
|
# scheduling priority, so the two deserve independent placement.
|
|
246
232
|
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
|
|
252
233
|
# Partition MaxTime for work jobs — the panel-augmented walltime clamps
|
|
253
234
|
# here (see MAX_ATTEMPT_JOB_MINUTES). Raise together with job_partition.
|
|
254
235
|
max_job_minutes: int = MAX_ATTEMPT_JOB_MINUTES
|
|
@@ -424,7 +405,7 @@ def contract_alarm(
|
|
|
424
405
|
target: str,
|
|
425
406
|
error: str | None,
|
|
426
407
|
now: float,
|
|
427
|
-
bot_login: str = "
|
|
408
|
+
bot_login: str = "",
|
|
428
409
|
) -> None:
|
|
429
410
|
"""Persistent contract failure must surface where humans look.
|
|
430
411
|
|
|
@@ -433,6 +414,7 @@ def contract_alarm(
|
|
|
433
414
|
opens ONE issue on the target repo; the next successful load closes
|
|
434
415
|
it and says so. Alarm plumbing is best-effort by construction: it
|
|
435
416
|
must never break the tick it reports for."""
|
|
417
|
+
bot_login = bot_login or _bot_login_default()
|
|
436
418
|
state_path = root / "contract-alarm.json"
|
|
437
419
|
try:
|
|
438
420
|
state = json.loads(state_path.read_text())
|
|
@@ -894,7 +876,8 @@ def _last_worked_ts(root: Path) -> float | None:
|
|
|
894
876
|
return None
|
|
895
877
|
val = float(ts)
|
|
896
878
|
return val if math.isfinite(val) else None
|
|
897
|
-
except Exception:
|
|
879
|
+
except Exception as exc:
|
|
880
|
+
log.debug("work marker unreadable, coalescing as if absent: %s", exc)
|
|
898
881
|
return None
|
|
899
882
|
|
|
900
883
|
|
|
@@ -1192,6 +1175,11 @@ def sweep(
|
|
|
1192
1175
|
except Exception as exc:
|
|
1193
1176
|
log.warning("sweep failed on %s: %s: %s", record.run_id, type(exc).__name__, exc)
|
|
1194
1177
|
|
|
1178
|
+
try:
|
|
1179
|
+
cancel_ended_launches(root, compute, now, dry_run)
|
|
1180
|
+
except Exception as exc:
|
|
1181
|
+
log.warning("cancel-on-end failed: %s: %s", type(exc).__name__, exc)
|
|
1182
|
+
|
|
1195
1183
|
return TickReport(
|
|
1196
1184
|
swept=len(records),
|
|
1197
1185
|
woken=tuple(woken),
|
|
@@ -1205,6 +1193,64 @@ def sweep(
|
|
|
1205
1193
|
)
|
|
1206
1194
|
|
|
1207
1195
|
|
|
1196
|
+
CANCEL_ON_END_WINDOW_S = 7 * 86400
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def cancel_ended_launches(
|
|
1200
|
+
root: Path, compute: Compute, now: float, dry_run: bool = False
|
|
1201
|
+
) -> list[str]:
|
|
1202
|
+
"""Cancel on end: a run that ended while its author's launches are still
|
|
1203
|
+
queued or running has nothing left to read their results, so the jobs are
|
|
1204
|
+
cancelled — a running one included, since that is the one holding GPUs for
|
|
1205
|
+
a result nobody will read. The stage is stamped once every cancel
|
|
1206
|
+
succeeded, so an ended run costs no query afterwards; a failed scancel
|
|
1207
|
+
leaves the run unstamped for the next tick, and runs ended longer ago than
|
|
1208
|
+
the window are stamped without a query (their jobs have long left the
|
|
1209
|
+
queue). Returns the cancelled ids."""
|
|
1210
|
+
from outerloop.attempt import stage_launch_job_ids
|
|
1211
|
+
|
|
1212
|
+
cancelled: list[str] = []
|
|
1213
|
+
for record in list_runs(root):
|
|
1214
|
+
if record.state != ENDED:
|
|
1215
|
+
continue
|
|
1216
|
+
stage = dict(record.stage or {})
|
|
1217
|
+
if stage.get("launches_cancelled"):
|
|
1218
|
+
continue
|
|
1219
|
+
job_ids = stage_launch_job_ids(record)
|
|
1220
|
+
recent = now - float(getattr(record, "updated", 0.0) or 0.0) <= CANCEL_ON_END_WINDOW_S
|
|
1221
|
+
live: list[str] = []
|
|
1222
|
+
if job_ids and recent:
|
|
1223
|
+
try:
|
|
1224
|
+
for jid in job_ids:
|
|
1225
|
+
state = compute.status(jid)
|
|
1226
|
+
if state != GONE and not is_terminal(state):
|
|
1227
|
+
live.append(jid)
|
|
1228
|
+
except SlurmQueryError as exc:
|
|
1229
|
+
log.warning("cancel-on-end: %s: query failed (%s); next tick", record.run_id, exc)
|
|
1230
|
+
continue
|
|
1231
|
+
if dry_run:
|
|
1232
|
+
cancelled.extend(live)
|
|
1233
|
+
continue
|
|
1234
|
+
failed = False
|
|
1235
|
+
for jid in live:
|
|
1236
|
+
try:
|
|
1237
|
+
ok = compute.cancel(jid)
|
|
1238
|
+
except Exception as exc:
|
|
1239
|
+
ok = False
|
|
1240
|
+
log.warning("cancel-on-end: %s: scancel %s failed: %s", record.run_id, jid, exc)
|
|
1241
|
+
if ok:
|
|
1242
|
+
cancelled.append(jid)
|
|
1243
|
+
else:
|
|
1244
|
+
failed = True
|
|
1245
|
+
if failed:
|
|
1246
|
+
continue # unstamped: the next tick tries again, until the window closes
|
|
1247
|
+
stage["launches_cancelled"] = True
|
|
1248
|
+
save_record(root, replace(record, stage=stage), now)
|
|
1249
|
+
if cancelled:
|
|
1250
|
+
log.info("cancel-on-end: cancelled %d launch job(s) of ended runs", len(cancelled))
|
|
1251
|
+
return cancelled
|
|
1252
|
+
|
|
1253
|
+
|
|
1208
1254
|
RESEARCH_LOG_BRANCH = "research-log"
|
|
1209
1255
|
RESEARCH_LOG_MARKER = marker("research-log")
|
|
1210
1256
|
RESEARCH_LOG_PER_TICK = 3
|
|
@@ -1793,6 +1839,12 @@ def tick(
|
|
|
1793
1839
|
# whether github/contract loaded this tick.
|
|
1794
1840
|
if followup_spec is not None:
|
|
1795
1841
|
service_syncs(root, followup_spec, now)
|
|
1842
|
+
# a dry run reports and writes nothing: no download, no seed
|
|
1843
|
+
if github is not None and followup_spec is not None and followup_spec.target and not dry_run:
|
|
1844
|
+
try:
|
|
1845
|
+
service_eval_cache(root, github, followup_spec.target)
|
|
1846
|
+
except Exception as exc: # advisory: a cold cache costs a download, never a tick
|
|
1847
|
+
log.warning("eval cache warm failed: %s: %s", type(exc).__name__, exc)
|
|
1796
1848
|
if github is not None and followup_spec is not None:
|
|
1797
1849
|
# expired flight snapshots die with their TTL, not with a human.
|
|
1798
1850
|
# One home suffices: every lane's spec derives from followup_spec
|
|
@@ -1863,12 +1915,6 @@ def tick(
|
|
|
1863
1915
|
allow_submit=launch_ok,
|
|
1864
1916
|
contract=contract,
|
|
1865
1917
|
)
|
|
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)
|
|
1872
1918
|
try:
|
|
1873
1919
|
service_research_log(root, github, spec, now)
|
|
1874
1920
|
except Exception as exc: # the ledger is advisory; the tick continues
|
|
@@ -1920,118 +1966,16 @@ def tick(
|
|
|
1920
1966
|
return report
|
|
1921
1967
|
|
|
1922
1968
|
|
|
1923
|
-
|
|
1969
|
+
def service_eval_cache(root: Path, github: Any, target: str) -> str:
|
|
1970
|
+
"""Warm the target's seed cache from its default branch's lockfile
|
|
1971
|
+
(docs/design/eval-cache.md). Cheap when nothing changed: two file reads
|
|
1972
|
+
and a hash. Returns the warmer's one-word status."""
|
|
1973
|
+
from outerloop.evalcache import warm
|
|
1924
1974
|
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
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
|
-
)
|
|
1975
|
+
status = warm(root, target, github, github.default_branch(target))
|
|
1976
|
+
if status != "unchanged":
|
|
1977
|
+
log.info("eval cache for %s: %s", target, status)
|
|
1978
|
+
return status
|
|
2035
1979
|
|
|
2036
1980
|
|
|
2037
1981
|
def service_syncs(root: Path, spec: Any, now: float) -> None:
|
|
@@ -2041,7 +1985,7 @@ def service_syncs(root: Path, spec: Any, now: float) -> None:
|
|
|
2041
1985
|
are written — safe next to the session's local git use. Best-effort per
|
|
2042
1986
|
run; a failure leaves the request standing for the next cycle."""
|
|
2043
1987
|
from outerloop.appauth import resolve_bot_auth
|
|
2044
|
-
from outerloop.attempt import
|
|
1988
|
+
from outerloop.attempt import target_clone_url
|
|
2045
1989
|
from outerloop.github import Workspace
|
|
2046
1990
|
from outerloop.syscall import mark_synced, sync_requested
|
|
2047
1991
|
|
|
@@ -2062,7 +2006,7 @@ def service_syncs(root: Path, spec: Any, now: float) -> None:
|
|
|
2062
2006
|
if (spec.pat_file or spec.github_app_file)
|
|
2063
2007
|
else None
|
|
2064
2008
|
),
|
|
2065
|
-
url=
|
|
2009
|
+
url=target_clone_url(record.target),
|
|
2066
2010
|
)
|
|
2067
2011
|
ws.fetch_origin()
|
|
2068
2012
|
mark_synced(workspace, requested_at)
|
|
@@ -2271,16 +2215,6 @@ def _pending_path(root: Path, target: str, agent: str = "") -> Path:
|
|
|
2271
2215
|
return root / "pending" / (target.replace("/", "__") + suffix + ".json")
|
|
2272
2216
|
|
|
2273
2217
|
|
|
2274
|
-
def read_pending(root: Path, target: str, agent: str = "") -> dict[str, Any] | None:
|
|
2275
|
-
"""The submit-time marker for a climb whose run record may not exist yet."""
|
|
2276
|
-
path = _pending_path(root, target, agent)
|
|
2277
|
-
try:
|
|
2278
|
-
data = json.loads(path.read_text())
|
|
2279
|
-
except (OSError, ValueError):
|
|
2280
|
-
return None
|
|
2281
|
-
return data if isinstance(data, dict) and "submitted_at" in data else None
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
2218
|
def list_pendings(root: Path, target: str) -> list[tuple[str, dict[str, Any]]]:
|
|
2285
2219
|
"""(agent, marker) for every live pending marker of `target` — one per
|
|
2286
2220
|
WIDTH slot, plus the legacy un-suffixed marker from a pre-width deploy
|
|
@@ -3119,21 +3053,6 @@ def _wake_dispatcher_from_env(
|
|
|
3119
3053
|
return JobWakeDispatcher(compute, followup_spec, now), True
|
|
3120
3054
|
|
|
3121
3055
|
|
|
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
|
-
|
|
3137
3056
|
def _max_job_minutes_from_env() -> int:
|
|
3138
3057
|
"""OUTERLOOP_MAX_JOB_MINUTES, clamped into what the code can honor:
|
|
3139
3058
|
at least the climb-job floor (an operator on a short-MaxTime partition
|
|
@@ -3240,16 +3159,9 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
|
|
|
3240
3159
|
# association and the partition to Slurm's defaults, as `start` already
|
|
3241
3160
|
# does for the resident. Local compute has no placement at all.
|
|
3242
3161
|
target = os.environ.get("OUTERLOOP_TARGET", "")
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
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
|
-
)
|
|
3162
|
+
# no default identity (#298): every own-comment and own-PR filter keys on
|
|
3163
|
+
# this login, and a wrong one is worse than none
|
|
3164
|
+
bot_login = os.environ.get("OUTERLOOP_BOT_LOGIN", "").strip()
|
|
3253
3165
|
image_ok = Path(image).is_file()
|
|
3254
3166
|
panel = os.environ.get("OUTERLOOP_PANEL", "verify,review")
|
|
3255
3167
|
if not image_ok and local_mode():
|
|
@@ -3276,7 +3188,7 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
|
|
|
3276
3188
|
# the jobs must not inherit a path to an image that is not there
|
|
3277
3189
|
os.environ.pop("OUTERLOOP_IMAGE", None)
|
|
3278
3190
|
image, image_ok = "", True
|
|
3279
|
-
if (pat_file or app_file) and home and target and image_ok:
|
|
3191
|
+
if (pat_file or app_file) and home and target and bot_login and image_ok:
|
|
3280
3192
|
from outerloop.appauth import resolve_bot_auth
|
|
3281
3193
|
from outerloop.github import GitHubClient
|
|
3282
3194
|
|
|
@@ -3298,7 +3210,6 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
|
|
|
3298
3210
|
gpu_partition=os.environ.get("OUTERLOOP_GPU_PARTITION", ""),
|
|
3299
3211
|
gpu_account=os.environ.get("OUTERLOOP_GPU_ACCOUNT", ""),
|
|
3300
3212
|
max_job_minutes=_max_job_minutes_from_env(),
|
|
3301
|
-
max_launch_gpus=max_launch_gpus_from_env(),
|
|
3302
3213
|
)
|
|
3303
3214
|
return github, followup_spec
|
|
3304
3215
|
except Exception as exc:
|
|
@@ -3310,6 +3221,7 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
|
|
|
3310
3221
|
("OUTERLOOP_PAT_FILE or _GITHUB_APP_FILE", pat_file or app_file),
|
|
3311
3222
|
("OUTERLOOP_HOME", home),
|
|
3312
3223
|
("OUTERLOOP_TARGET", target),
|
|
3224
|
+
("OUTERLOOP_BOT_LOGIN", bot_login),
|
|
3313
3225
|
]
|
|
3314
3226
|
if not value
|
|
3315
3227
|
]
|
outerloop/verify_agent.py
CHANGED
|
@@ -20,7 +20,7 @@ from outerloop.contract import find_contract
|
|
|
20
20
|
from outerloop.github import GitHubClient
|
|
21
21
|
from outerloop.harness import Harness, backend_id, budget_exhausted, outage
|
|
22
22
|
from outerloop.posting import EXPECTED_FAILURES, post_round, post_skip_stub
|
|
23
|
-
from outerloop.review_agent import
|
|
23
|
+
from outerloop.review_agent import emit_envelope, pull_request
|
|
24
24
|
from outerloop.role_runner import run_role
|
|
25
25
|
from outerloop.roles import verifier_spec, verify_result_from_role
|
|
26
26
|
from outerloop.rolespec import RoleSpec
|
|
@@ -81,19 +81,21 @@ def run_agent_verify(
|
|
|
81
81
|
# `detail` is already api-key-redacted by the harness (it owns its own
|
|
82
82
|
# secret), so no secret is passed here.
|
|
83
83
|
if emit_path is not None:
|
|
84
|
-
|
|
84
|
+
emit_envelope(
|
|
85
|
+
emit_path, repo, number, kind="skip-stub", detail=detail, reviewed_by=reviewed_by
|
|
86
|
+
)
|
|
85
87
|
else:
|
|
86
88
|
post_skip_stub(client, repo, number, "verification", RuntimeError(detail))
|
|
87
89
|
|
|
88
90
|
try:
|
|
89
|
-
pr, pr_data =
|
|
91
|
+
pr, pr_data = pull_request(client, repo, number)
|
|
90
92
|
skip = verify_skip_reason(pr, bot_login)
|
|
91
93
|
if skip is not None:
|
|
92
94
|
log.info("skipping verification of %s#%s: %s", repo, number, skip)
|
|
93
95
|
# a clean skip still leaves an envelope so the post job can REQUIRE
|
|
94
96
|
# an artifact — a missing one then always means a broken session
|
|
95
97
|
if emit_path is not None:
|
|
96
|
-
|
|
98
|
+
emit_envelope(emit_path, repo, number, kind="skip-clean", detail=skip)
|
|
97
99
|
return None
|
|
98
100
|
|
|
99
101
|
contract_text = _base_contract(client, repo, pr_data)
|
|
@@ -122,11 +124,11 @@ def run_agent_verify(
|
|
|
122
124
|
elif emit_path is not None:
|
|
123
125
|
# nothing worth posting, but the post job still needs an
|
|
124
126
|
# artifact so a MISSING one always means a broken session
|
|
125
|
-
|
|
127
|
+
emit_envelope(emit_path, repo, number, kind="skip-clean", detail=detail)
|
|
126
128
|
return None
|
|
127
129
|
|
|
128
130
|
if emit_path is not None:
|
|
129
|
-
|
|
131
|
+
emit_envelope(
|
|
130
132
|
emit_path,
|
|
131
133
|
repo,
|
|
132
134
|
number,
|
outerloop/verify_post_cli.py
CHANGED
|
@@ -20,7 +20,7 @@ from pathlib import Path
|
|
|
20
20
|
|
|
21
21
|
from outerloop.github import EnvTokenProvider, GitHubClient
|
|
22
22
|
from outerloop.posting import EXPECTED_FAILURES, post_round, post_skip_stub
|
|
23
|
-
from outerloop.review_agent import
|
|
23
|
+
from outerloop.review_agent import pull_request
|
|
24
24
|
from outerloop.verifier import (
|
|
25
25
|
VERIFY_MARKER,
|
|
26
26
|
format_verify_comment,
|
|
@@ -58,7 +58,7 @@ def post_from_file(
|
|
|
58
58
|
# the write authority re-checks the bot-only rule for EVERY kind: this
|
|
59
59
|
# side of the artifact boundary is the one that must never post on a
|
|
60
60
|
# non-bot PR — a forged envelope is still a post
|
|
61
|
-
pr, pr_data =
|
|
61
|
+
pr, pr_data = pull_request(client, repo, number)
|
|
62
62
|
skip = verify_skip_reason(pr, bot_login)
|
|
63
63
|
if skip is not None:
|
|
64
64
|
log.info("skipping post on %s#%s: %s", repo, number, skip)
|
outerloop/watcher.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""The session watcher: a thread beside a live author session that answers its
|
|
2
|
+
questions through the syscall channel in seconds — `queue` (the kernel's jobs
|
|
3
|
+
in the cluster queue, every agent's) and `history` (this run's launches) —
|
|
4
|
+
where the tick would take a cadence. It changes no lifecycle state: it writes
|
|
5
|
+
answer files into the channel and nothing else; submitting, cancelling,
|
|
6
|
+
sealing and parking stay at the sleep boundary (docs/design/session-watcher.md).
|
|
7
|
+
|
|
8
|
+
Failure never reaches the session: an exception is logged and the request is
|
|
9
|
+
left standing, the tool times out and says so. A watcher that dies leaves the
|
|
10
|
+
session exactly as it is without one."""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from outerloop.climbboard import queue_rows
|
|
24
|
+
from outerloop.launchlog import history, why_by_job
|
|
25
|
+
from outerloop.runstate import run_dir
|
|
26
|
+
from outerloop.syscall import MAX_WHY_CHARS, mark_done, marker_requested, write_channel_json
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
VERBS = ("queue", "history")
|
|
31
|
+
POLL_S = 2.0
|
|
32
|
+
# one scheduler query per this many seconds however often the marker is
|
|
33
|
+
# rewritten: a session cannot turn the watcher into a squeue loop
|
|
34
|
+
QUERY_GAP_S = 5.0
|
|
35
|
+
MAX_QUEUE_ROWS = 500
|
|
36
|
+
# squeue names a pending array `<id>_[0-15%4]` and a running task `<id>_3`
|
|
37
|
+
_ARRAY_THROTTLE = re.compile(r"_\[[^\]]*%(\d+)\]$")
|
|
38
|
+
# the lane's node states move slowly; one sinfo a minute is plenty
|
|
39
|
+
LANE_GAP_S = 60.0
|
|
40
|
+
# a stopped watcher may still be inside a scheduler query (its own timeout,
|
|
41
|
+
# a minute at most); the attempt waits this long for it, then moves on — the
|
|
42
|
+
# thread is a daemon and writes nothing once stopped
|
|
43
|
+
JOIN_S = 15.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class WatcherContext:
|
|
48
|
+
"""What the watcher needs to answer: where the channel is, which run it
|
|
49
|
+
serves, and the compute backend to ask (None = no queue, local mode)."""
|
|
50
|
+
|
|
51
|
+
workspace: Path
|
|
52
|
+
run_root: Path
|
|
53
|
+
run_id: str
|
|
54
|
+
target: str
|
|
55
|
+
agent_id: str
|
|
56
|
+
compute: Any = None
|
|
57
|
+
gpu_partition: str = ""
|
|
58
|
+
poll_s: float = POLL_S
|
|
59
|
+
query_gap_s: float = QUERY_GAP_S
|
|
60
|
+
lane_gap_s: float = LANE_GAP_S
|
|
61
|
+
clock: Callable[[], float] = field(default=time.time)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _kind(name: str) -> str:
|
|
65
|
+
"""A short label for a kernel job that is not a launch."""
|
|
66
|
+
for key in ("wake", "followup", "eval", "resident", "tick"):
|
|
67
|
+
if key in name:
|
|
68
|
+
return key
|
|
69
|
+
return name
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SessionWatcher:
|
|
73
|
+
"""`with SessionWatcher(ctx):` around the harness subprocess. `service()`
|
|
74
|
+
is one pass, public so the loop is testable without the thread."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, ctx: WatcherContext) -> None:
|
|
77
|
+
self.ctx = ctx
|
|
78
|
+
self._stop = threading.Event()
|
|
79
|
+
self._thread: threading.Thread | None = None
|
|
80
|
+
self._queue_cache: dict[str, Any] | None = None
|
|
81
|
+
self._queue_at = 0.0
|
|
82
|
+
self._lane_cache: dict[str, Any] | None = None
|
|
83
|
+
self._lane_at = 0.0
|
|
84
|
+
|
|
85
|
+
def __enter__(self) -> SessionWatcher:
|
|
86
|
+
self._thread = threading.Thread(target=self._loop, name="session-watcher", daemon=True)
|
|
87
|
+
self._thread.start()
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
def __exit__(self, *exc: object) -> None:
|
|
91
|
+
self._stop.set()
|
|
92
|
+
if self._thread is not None:
|
|
93
|
+
self._thread.join(timeout=JOIN_S)
|
|
94
|
+
if self._thread.is_alive():
|
|
95
|
+
log.warning("session watcher still inside a scheduler query at session end")
|
|
96
|
+
|
|
97
|
+
def _loop(self) -> None:
|
|
98
|
+
# wait() is False on timeout: poll; True once stopped
|
|
99
|
+
while not self._stop.wait(self.ctx.poll_s):
|
|
100
|
+
self.service()
|
|
101
|
+
|
|
102
|
+
def service(self) -> None:
|
|
103
|
+
"""Answer every verb whose request marker is newer than its done marker."""
|
|
104
|
+
for verb in VERBS:
|
|
105
|
+
if self._stop.is_set():
|
|
106
|
+
return
|
|
107
|
+
try:
|
|
108
|
+
requested = marker_requested(self.ctx.workspace, f"{verb}-request", f"{verb}-done")
|
|
109
|
+
if requested is None:
|
|
110
|
+
continue
|
|
111
|
+
payload = self.queue_view() if verb == "queue" else self.history_view()
|
|
112
|
+
if self._stop.is_set():
|
|
113
|
+
return # the session ended meanwhile: nothing is written after it
|
|
114
|
+
write_channel_json(self.ctx.workspace, f"{verb}.json", payload)
|
|
115
|
+
mark_done(self.ctx.workspace, f"{verb}-done", requested)
|
|
116
|
+
except Exception as exc: # the request stands; the tool times out
|
|
117
|
+
log.warning("session watcher: %s failed: %s", verb, exc)
|
|
118
|
+
|
|
119
|
+
def history_view(self) -> dict[str, Any]:
|
|
120
|
+
ctx = self.ctx
|
|
121
|
+
return {
|
|
122
|
+
"at": ctx.clock(),
|
|
123
|
+
"run_id": ctx.run_id,
|
|
124
|
+
"history": history(run_dir(ctx.run_root, ctx.run_id)),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
def queue_view(self) -> dict[str, Any]:
|
|
128
|
+
"""The kernel's jobs as the board projects them (attributed to agents),
|
|
129
|
+
each launch labelled with its author's `why`, plus the GPU lane's load.
|
|
130
|
+
Cached for `query_gap_s` — the rate limit on scheduler queries."""
|
|
131
|
+
ctx = self.ctx
|
|
132
|
+
now = ctx.clock()
|
|
133
|
+
if self._queue_cache is not None and now - self._queue_at < ctx.query_gap_s:
|
|
134
|
+
return {**self._queue_cache, "at": now, "cached": True}
|
|
135
|
+
error = ""
|
|
136
|
+
rows: list[dict[str, Any]] = []
|
|
137
|
+
try:
|
|
138
|
+
snapshot = ctx.compute.queue_snapshot() if ctx.compute is not None else []
|
|
139
|
+
rows = [
|
|
140
|
+
dict(r) for r in queue_rows(ctx.run_root, ctx.target, snapshot)[:MAX_QUEUE_ROWS]
|
|
141
|
+
]
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
error = str(exc)[:200]
|
|
144
|
+
labels: dict[str, dict[str, Any]] = {}
|
|
145
|
+
for rid in {str(r.get("run_id") or "") for r in rows} - {""}:
|
|
146
|
+
labels.update(why_by_job(run_dir(ctx.run_root, rid)))
|
|
147
|
+
for r in rows:
|
|
148
|
+
rid = str(r.get("run_id") or "")
|
|
149
|
+
name = str(r.get("name") or "")
|
|
150
|
+
prefix = f"{rid}-launch-" if rid else ""
|
|
151
|
+
r["mine"] = bool(rid) and rid == ctx.run_id
|
|
152
|
+
job_id = str(r.get("id") or "")
|
|
153
|
+
throttle = _ARRAY_THROTTLE.search(job_id)
|
|
154
|
+
if throttle:
|
|
155
|
+
r["concurrency"] = int(throttle.group(1))
|
|
156
|
+
if prefix and name.startswith(prefix):
|
|
157
|
+
r["experiment"] = name[len(prefix) :]
|
|
158
|
+
# the ledger keys the array's id; squeue shows `<id>_<k>` or `<id>_[...]`
|
|
159
|
+
base = job_id.split("_", 1)[0]
|
|
160
|
+
label = labels.get(base, {})
|
|
161
|
+
r["why"] = str(label.get("why", ""))[:MAX_WHY_CHARS]
|
|
162
|
+
r["kind"] = "launch"
|
|
163
|
+
# the pace: the pending range says it; once only running tasks
|
|
164
|
+
# are left, the ledger does (the whole array when unset)
|
|
165
|
+
array = int(label.get("array") or 1)
|
|
166
|
+
if "concurrency" not in r and array > 1:
|
|
167
|
+
r["concurrency"] = int(label.get("concurrency") or array)
|
|
168
|
+
else:
|
|
169
|
+
r["experiment"] = ""
|
|
170
|
+
r["why"] = ""
|
|
171
|
+
r["kind"] = _kind(name)
|
|
172
|
+
view = {
|
|
173
|
+
"at": now,
|
|
174
|
+
"run_id": ctx.run_id,
|
|
175
|
+
"agent": ctx.agent_id,
|
|
176
|
+
"jobs": rows,
|
|
177
|
+
"lane": self._lane_load(),
|
|
178
|
+
"error": error,
|
|
179
|
+
}
|
|
180
|
+
self._queue_cache, self._queue_at = view, now
|
|
181
|
+
return view
|
|
182
|
+
|
|
183
|
+
def _lane_load(self) -> dict[str, Any]:
|
|
184
|
+
"""The GPU lane's node states, refreshed once per `lane_gap_s` (a
|
|
185
|
+
second scheduler query per request would defeat the rate limit). A
|
|
186
|
+
failed sinfo is reported in the answer, never passed off as a lane
|
|
187
|
+
with no state."""
|
|
188
|
+
ctx = self.ctx
|
|
189
|
+
load = getattr(ctx.compute, "lane_load", None)
|
|
190
|
+
if load is None or not ctx.gpu_partition:
|
|
191
|
+
return {}
|
|
192
|
+
now = ctx.clock()
|
|
193
|
+
if self._lane_cache is not None and now - self._lane_at < ctx.lane_gap_s:
|
|
194
|
+
return self._lane_cache
|
|
195
|
+
try:
|
|
196
|
+
lane: dict[str, Any] = {
|
|
197
|
+
"partition": ctx.gpu_partition,
|
|
198
|
+
"nodes": load(ctx.gpu_partition),
|
|
199
|
+
}
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
lane = {"partition": ctx.gpu_partition, "nodes": {}, "error": str(exc)[:200]}
|
|
202
|
+
self._lane_cache, self._lane_at = lane, now
|
|
203
|
+
return lane
|