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/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 AUTORESEARCH_DISPATCH_WAKE env var still
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: AUTORESEARCH_MIN_TICK_MINUTES.
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 (AUTORESEARCH_JOB_PARTITION=cpu48) raises the cap with
157
- # AUTORESEARCH_MAX_JOB_MINUTES. Code-side ceiling: the cap must stay under
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 # AUTORESEARCH_HOME: cwd for the submitted job
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 AUTORESEARCH_GITHUB_APP_FILE from
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 AUTORESEARCH_PANEL="".
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 AUTORESEARCH_GPU_PARTITION)"
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
- if record.wake_attempts >= MAX_WAKE_ATTEMPTS:
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
- "uv",
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
- "--image",
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("AUTORESEARCH_DISPATCH_WAKE", "").strip())
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(root: Path, github: Any, target: str, contract: Any, now: float) -> None:
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
- service_status(root, github, target, now, contract)
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. AUTORESEARCH_AUTHOR_BACKEND=codex with no non-claude model) never
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("AUTORESEARCH_AUTHOR_BACKEND") or "claude"
2124
- model = os.environ.get("AUTORESEARCH_AUTHOR_MODEL") or "claude-opus-5"
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": "AUTORESEARCH_PANEL_CODEX_KEY_FILE",
2156
- "hermes": "AUTORESEARCH_PANEL_HERMES_KEY_FILE",
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"(AUTORESEARCH_IMAGE={spec.image!r})"
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("AUTORESEARCH_AUTHOR_BACKEND") or "claude"
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 AUTORESEARCH_PANEL, or the "
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 AUTORESEARCH_AUTHOR_BACKEND/_MODEL)",
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 AUTORESEARCH_PANEL='' to disable the panel)",
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
- "uv",
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
- "--image",
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
- # AUTORESEARCH_AUTHOR_* env (inherited by the job), so the tick threads
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
- "uv",
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
- "--image",
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 AUTORESEARCH_PANEL, or the "
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 AUTORESEARCH_AUTHOR_BACKEND/_MODEL)",
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 AUTORESEARCH_PANEL='' to disable the panel)",
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
- "uv",
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
- "--image",
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
- # AUTORESEARCH_AUTHOR_* env by backend; the tick does not thread it.
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
- "uv",
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
- "--image",
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 `AUTORESEARCH_DISPATCH_WAKE` env var OR a `<root>/DISPATCH_WAKE`
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
- """AUTORESEARCH_MAX_JOB_MINUTES, clamped into what the code can honor:
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("AUTORESEARCH_MAX_JOB_MINUTES", "").strip()
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("AUTORESEARCH_MAX_JOB_MINUTES=%r is not an integer; using default", raw)
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("AUTORESEARCH_MAX_JOB_MINUTES=%d clamped to %d", value, clamped)
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 (AUTORESEARCH_CADENCE_MIN, the same
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("AUTORESEARCH_CADENCE_MIN", "").strip()
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 AUTORESEARCH_MIN_TICK_MINUTES: half the cadence (so an on-cadence
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
- """AUTORESEARCH_MIN_TICK_MINUTES -> the coalesce window in seconds. Unset
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("AUTORESEARCH_MIN_TICK_MINUTES", "").strip()
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("AUTORESEARCH_MIN_TICK_MINUTES=%r is not a number; using default", raw)
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("AUTORESEARCH_MIN_TICK_MINUTES=%r is not finite; using default", raw)
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
- "AUTORESEARCH_MIN_TICK_MINUTES=%s exceeds the safe ceiling "
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("AUTORESEARCH_PAT_FILE", "")
2986
- app_file = os.environ.get("AUTORESEARCH_GITHUB_APP_FILE", "")
2987
- account = os.environ.get("AUTORESEARCH_ACCOUNT", "")
2988
- partition = os.environ.get("AUTORESEARCH_PARTITION", "")
2989
- image = os.environ.get(
2990
- "AUTORESEARCH_IMAGE",
2991
- os.path.expanduser("~/autoresearch-images/agent-py312.sif"),
2992
- )
2993
- home = os.environ.get("AUTORESEARCH_HOME", "")
2994
- # Local compute runs jobs as subprocesses: Slurm placement is meaningless
2995
- # there, so account/partition are only required when a cluster is in play.
2996
- placed = bool(account and partition) or local_mode()
2997
- if (pat_file or app_file) and placed and home and Path(image).is_file():
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=os.environ.get(
3012
- "AUTORESEARCH_TARGET", "agentic-learning-ai-lab/autoresearch-pilot"
3013
- ),
3014
- steward_key_file=os.environ.get("AUTORESEARCH_STEWARD_KEY_FILE", ""),
3015
- panel=os.environ.get("AUTORESEARCH_PANEL", "verify,review"),
3016
- panel_key_file=os.environ.get("AUTORESEARCH_PANEL_KEY_FILE", ""),
3017
- job_partition=os.environ.get("AUTORESEARCH_JOB_PARTITION", ""),
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
- ("AUTORESEARCH_PAT_FILE or _GITHUB_APP_FILE", pat_file or app_file),
3030
- ("AUTORESEARCH_ACCOUNT", account or ("-" if local_mode() else "")),
3031
- ("AUTORESEARCH_PARTITION", partition or ("-" if local_mode() else "")),
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 Path(image).is_file():
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 AUTORESEARCH_CADENCE_MIN."""
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(description="One tick of the autoresearch loop.")
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("--grace-s", type=float, default=DEFAULT_GRACE_S)
3056
- parser.add_argument("--lease-ttl-s", type=float, default=DEFAULT_LEASE_TTL_S)
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
- "AUTORESEARCH_CADENCE_MIN via the chain's own parser (default 30)",
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 AUTORESEARCH_ROOT, so a bare `tick --loop --root X`
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("AUTORESEARCH_ROOT", "") != str(args.root.resolve()):
3087
- os.environ["AUTORESEARCH_ROOT"] = str(args.root.resolve())
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 AUTORESEARCH_DISPATCH_WAKE env var or a
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