outerloop-science 0.1.0.dev1__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
@@ -36,6 +36,7 @@ from outerloop.compute import (
36
36
  SlurmError,
37
37
  SlurmQueryError,
38
38
  compute_from_env,
39
+ gpus_in_gres,
39
40
  is_pending,
40
41
  is_terminal,
41
42
  local_mode,
@@ -74,7 +75,7 @@ log = logging.getLogger(__name__)
74
75
  PAUSE_SENTINEL = "PAUSE"
75
76
  # Operator on-switch for dispatched-wake, mirroring PAUSE: a root-relative
76
77
  # sentinel an operator arms/disarms with a touch/rm — no chain restart, no
77
- # 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
78
79
  # works too (either arms it); the sentinel is the reversible, restart-free path.
79
80
  DISPATCH_WAKE_SENTINEL = "DISPATCH_WAKE"
80
81
  HEARTBEAT_NAME = "heartbeat.json"
@@ -88,6 +89,34 @@ DEFAULT_GRACE_S = 15 * 60
88
89
  # a blind park (no job ids to poll) waits its eval walltime plus this queue
89
90
  # slack before a follow-up is sent to look for the result
90
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
+
91
120
  # A held lease is stale after the session timeout plus slack.
92
121
  DEFAULT_LEASE_TTL_S = 3600 + 15 * 60
93
122
  # Coalesce guard: skip a tick's work if another ran within this window. Under
@@ -95,7 +124,7 @@ DEFAULT_LEASE_TTL_S = 3600 + 15 * 60
95
124
  # (serialized by the singleton dependency), so they would run back-to-back and
96
125
  # redundantly re-sweep. The chain schedules ticks a full cadence apart by
97
126
  # begin-time, so only late-bunched pile-ups fall inside this window; keep it
98
- # 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.
99
128
  DEFAULT_MIN_TICK_S = 10 * 60
100
129
  # Ceiling for the coalesce window: a value above this is almost certainly a typo
101
130
  # (a window near/over the cadence would coalesce every on-cadence tick and stall
@@ -154,8 +183,8 @@ class TickReport:
154
183
  # The submitted walltime must never exceed the job partition's MaxTime —
155
184
  # sbatch REJECTS a longer request outright, which would ground every climb.
156
185
  # The DEFAULT matches cpu_short (6 h); an operator moving work jobs to a
157
- # longer partition (AUTORESEARCH_JOB_PARTITION=cpu48) raises the cap with
158
- # 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
159
188
  # STRANDED_IMPLEMENTING_S or the picker declares live runs stranded — jobs
160
189
  # longer than 10 h need that window made spec-aware first (named gap). The
161
190
  # self-deadline arms at the CLAMPED value, so a job that wanted more time
@@ -181,12 +210,12 @@ class FollowupSpec:
181
210
  partition: str
182
211
  run_root: Path
183
212
  image: str
184
- home: Path # AUTORESEARCH_HOME: cwd for the submitted job
213
+ home: Path # OUTERLOOP_HOME: cwd for the submitted job
185
214
  bot_login: str = field(default_factory=_bot_login_default)
186
215
  time_minutes: int = 90 # min()'d with the contract's followup_job_minutes
187
216
  max_turns: int = DEFAULT_MAX_TURNS # session turn budget for follow-up jobs
188
217
  pat_file: str = "" # forwarded to the job; "" = the followup CLI default
189
- # GitHub App config path; jobs inherit AUTORESEARCH_GITHUB_APP_FILE from
218
+ # GitHub App config path; jobs inherit OUTERLOOP_GITHUB_APP_FILE from
190
219
  # the tick environment, so it is never threaded through argv
191
220
  github_app_file: str = ""
192
221
  target: str = "" # the repo the intake pass scans for requested-lane issues
@@ -194,7 +223,7 @@ class FollowupSpec:
194
223
  # until the operator provisions it
195
224
  steward_key_file: str = ""
196
225
  # Pre-PR verification panel for climb jobs (docs/design/orchestrator-verify.md).
197
- # 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="".
198
227
  # The climb CLI fails LOUDLY on a bad panel config (a configured gate must
199
228
  # never silently vanish); the tick preflights the same rules — lens
200
229
  # grammar AND key file — before claiming or submitting so nothing is
@@ -215,6 +244,11 @@ class FollowupSpec:
215
244
  # are minutes, work jobs can be hours, and Slurm prices walltime into
216
245
  # scheduling priority, so the two deserve independent placement.
217
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
218
252
  # Partition MaxTime for work jobs — the panel-augmented walltime clamps
219
253
  # here (see MAX_ATTEMPT_JOB_MINUTES). Raise together with job_partition.
220
254
  max_job_minutes: int = MAX_ATTEMPT_JOB_MINUTES
@@ -286,7 +320,7 @@ def _gpu_lane_error(contract: Any, benchmark: str, spec: FollowupSpec) -> str:
286
320
  if gpu_benches:
287
321
  return (
288
322
  f"contract has GPU benchmarks ({', '.join(gpu_benches)}) but no GPU lane is "
289
- "configured (set AUTORESEARCH_GPU_PARTITION)"
323
+ "configured (set OUTERLOOP_GPU_PARTITION)"
290
324
  )
291
325
  return ""
292
326
 
@@ -961,7 +995,7 @@ def dispatch_wake_armed(root: Path) -> bool:
961
995
  sentinel file (touch/rm, no chain restart). Read by the tick and by every
962
996
  park, so a disarm takes effect at once."""
963
997
  return (
964
- bool(os.environ.get("AUTORESEARCH_DISPATCH_WAKE", "").strip())
998
+ bool(os.environ.get("OUTERLOOP_DISPATCH_WAKE", "").strip())
965
999
  or (root / DISPATCH_WAKE_SENTINEL).exists()
966
1000
  )
967
1001
 
@@ -1612,6 +1646,32 @@ def _sweep_one(
1612
1646
  if now - record.terminal_seen >= grace_s:
1613
1647
  wake(record, f"experiment {state}", state)
1614
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
1615
1675
  # Unschedulable in practice: cancel every non-terminal job
1616
1676
  # (best-effort — scancel trouble must not abort the sweep), then
1617
1677
  # wake with that fact.
@@ -1803,6 +1863,12 @@ def tick(
1803
1863
  allow_submit=launch_ok,
1804
1864
  contract=contract,
1805
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)
1806
1872
  try:
1807
1873
  service_research_log(root, github, spec, now)
1808
1874
  except Exception as exc: # the ledger is advisory; the tick continues
@@ -1854,6 +1920,120 @@ def tick(
1854
1920
  return report
1855
1921
 
1856
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
+
1857
2037
  def service_syncs(root: Path, spec: Any, now: float) -> None:
1858
2038
  """Honor mid-leg sync requests: a LIVE session asked for fresh origin/*
1859
2039
  refs and is waiting inside its own clock. The fetch pins the canonical
@@ -2171,13 +2351,13 @@ def _climb_panel_argv(spec: FollowupSpec) -> list[str]:
2171
2351
  def _author_config_error(spec: FollowupSpec) -> str:
2172
2352
  """Why the config-driven author would die at the climb's startup ("" when it
2173
2353
  won't), checked on the tick host BEFORE a claim/submit so a codex misconfig
2174
- (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
2175
2355
  strands a claimed intake issue. Reads the fleet author config from env — the
2176
2356
  same source the climb defaults from — and the image the tick already knows."""
2177
2357
  from outerloop.attempt import codex_author_config_error
2178
2358
 
2179
- backend = os.environ.get("AUTORESEARCH_AUTHOR_BACKEND") or "claude"
2180
- 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"
2181
2361
  return codex_author_config_error(backend, model, spec.image)
2182
2362
 
2183
2363
 
@@ -2208,8 +2388,8 @@ def _panel_preflight_error(spec: FollowupSpec) -> str:
2208
2388
  # neither the author's nor the claude panel key + readable), and for
2209
2389
  # hermes its pinned clone.
2210
2390
  shelled = {
2211
- "codex": "AUTORESEARCH_PANEL_CODEX_KEY_FILE",
2212
- "hermes": "AUTORESEARCH_PANEL_HERMES_KEY_FILE",
2391
+ "codex": "OUTERLOOP_PANEL_CODEX_KEY_FILE",
2392
+ "hermes": "OUTERLOOP_PANEL_HERMES_KEY_FILE",
2213
2393
  }
2214
2394
  for lens_backend, key_env in shelled.items():
2215
2395
  if not any(backend == lens_backend for _, backend, _ in lenses):
@@ -2217,7 +2397,7 @@ def _panel_preflight_error(spec: FollowupSpec) -> str:
2217
2397
  if not spec.image or not Path(spec.image).is_file():
2218
2398
  return (
2219
2399
  f"a {lens_backend} panel lens requires a real container image "
2220
- f"(AUTORESEARCH_IMAGE={spec.image!r})"
2400
+ f"(OUTERLOOP_IMAGE={spec.image!r})"
2221
2401
  )
2222
2402
  key_raw = os.environ.get(key_env, "").strip()
2223
2403
  if not key_raw:
@@ -2273,7 +2453,7 @@ def _panel_preflight_error(spec: FollowupSpec) -> str:
2273
2453
  # (claude vs codex keys coexist), config-driven like the climb itself — so
2274
2454
  # the role-separation check compares the panel key against the RIGHT author
2275
2455
  # key, and a codex run is never judged by a stray Claude key.
2276
- fleet_backend = os.environ.get("AUTORESEARCH_AUTHOR_BACKEND") or "claude"
2456
+ fleet_backend = os.environ.get("OUTERLOOP_AUTHOR_BACKEND") or "claude"
2277
2457
  author = Path(resolve_author_key_file(fleet_backend))
2278
2458
  if not author.is_absolute():
2279
2459
  # same rule as the panel key: the climb resolves paths from a
@@ -2462,7 +2642,7 @@ def service_self_initiated(
2462
2642
  # that would publish panel-less self-merging PRs (terra #171)
2463
2643
  log.error(
2464
2644
  "attempt on %s not launched: contract sets merge:auto but "
2465
- "no panel is configured (set AUTORESEARCH_PANEL, or the "
2645
+ "no panel is configured (set OUTERLOOP_PANEL, or the "
2466
2646
  "contract back to merge:manual)",
2467
2647
  benchmark,
2468
2648
  )
@@ -2474,7 +2654,7 @@ def service_self_initiated(
2474
2654
  if author_error:
2475
2655
  log.error(
2476
2656
  "climb on %s not launched: author misconfigured — %s "
2477
- "(fix AUTORESEARCH_AUTHOR_BACKEND/_MODEL)",
2657
+ "(fix OUTERLOOP_AUTHOR_BACKEND/_MODEL)",
2478
2658
  benchmark,
2479
2659
  author_error,
2480
2660
  )
@@ -2483,7 +2663,7 @@ def service_self_initiated(
2483
2663
  if panel_error:
2484
2664
  log.error(
2485
2665
  "climb on %s not launched: panel misconfigured — %s "
2486
- "(fix it, or set AUTORESEARCH_PANEL='' to disable the panel)",
2666
+ "(fix it, or set OUTERLOOP_PANEL='' to disable the panel)",
2487
2667
  benchmark,
2488
2668
  panel_error,
2489
2669
  )
@@ -2510,7 +2690,7 @@ def service_self_initiated(
2510
2690
  if spec.pat_file:
2511
2691
  argv += ["--pat-file", spec.pat_file]
2512
2692
  # config-driven author: climb resolves the author backend/model/key from
2513
- # AUTORESEARCH_AUTHOR_* env (inherited by the job), so the tick threads
2693
+ # OUTERLOOP_AUTHOR_* env (inherited by the job), so the tick threads
2514
2694
  # neither the backend nor its key — a new backend needs zero tick change.
2515
2695
  job_id = compute.submit(
2516
2696
  JobSpec(
@@ -2714,7 +2894,7 @@ def service_intake(
2714
2894
  # that would publish panel-less self-merging PRs (terra #171)
2715
2895
  log.error(
2716
2896
  "attempt on %s not launched: contract sets merge:auto but "
2717
- "no panel is configured (set AUTORESEARCH_PANEL, or the "
2897
+ "no panel is configured (set OUTERLOOP_PANEL, or the "
2718
2898
  "contract back to merge:manual)",
2719
2899
  task.benchmark,
2720
2900
  )
@@ -2726,7 +2906,7 @@ def service_intake(
2726
2906
  if author_error:
2727
2907
  log.error(
2728
2908
  "issue #%d not claimed: author misconfigured — %s "
2729
- "(fix AUTORESEARCH_AUTHOR_BACKEND/_MODEL)",
2909
+ "(fix OUTERLOOP_AUTHOR_BACKEND/_MODEL)",
2730
2910
  task.number,
2731
2911
  author_error,
2732
2912
  )
@@ -2735,7 +2915,7 @@ def service_intake(
2735
2915
  if panel_error:
2736
2916
  log.error(
2737
2917
  "issue #%d not claimed: panel misconfigured — %s "
2738
- "(fix it, or set AUTORESEARCH_PANEL='' to disable the panel)",
2918
+ "(fix it, or set OUTERLOOP_PANEL='' to disable the panel)",
2739
2919
  task.number,
2740
2920
  panel_error,
2741
2921
  )
@@ -2777,7 +2957,7 @@ def service_intake(
2777
2957
  if spec.pat_file:
2778
2958
  argv += ["--pat-file", spec.pat_file]
2779
2959
  # config-driven author: climb resolves the author key from the
2780
- # AUTORESEARCH_AUTHOR_* env by backend; the tick does not thread it.
2960
+ # OUTERLOOP_AUTHOR_* env by backend; the tick does not thread it.
2781
2961
  try:
2782
2962
  job_id = compute.submit(
2783
2963
  JobSpec(
@@ -2921,7 +3101,7 @@ def _wake_dispatcher_from_env(
2921
3101
  """The wake delivery for this tick, behind an EXPLICIT on-switch so the
2922
3102
  dispatched-wake path lands DARK. Returns `(dispatcher, live)`:
2923
3103
 
2924
- * 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`
2925
3105
  sentinel file) AND the chain env carries what a wake job needs -> the real
2926
3106
  `JobWakeDispatcher` and a LIVE sweep;
2927
3107
  * otherwise -> the `LoggingDispatcher` and a DRY sweep.
@@ -2939,8 +3119,23 @@ def _wake_dispatcher_from_env(
2939
3119
  return JobWakeDispatcher(compute, followup_spec, now), True
2940
3120
 
2941
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
+
2942
3137
  def _max_job_minutes_from_env() -> int:
2943
- """AUTORESEARCH_MAX_JOB_MINUTES, clamped into what the code can honor:
3138
+ """OUTERLOOP_MAX_JOB_MINUTES, clamped into what the code can honor:
2944
3139
  at least the climb-job floor (an operator on a short-MaxTime partition
2945
3140
  must be able to LOWER the cap below cpu_short's 6h, or every submit is
2946
3141
  rejected), at most the ceiling the stranded window allows. A clamped
@@ -2948,24 +3143,24 @@ def _max_job_minutes_from_env() -> int:
2948
3143
  rejecting jobs for no reason."""
2949
3144
  from outerloop.limits import ATTEMPT_JOB_MINUTES_FLOOR
2950
3145
 
2951
- raw = os.environ.get("AUTORESEARCH_MAX_JOB_MINUTES", "").strip()
3146
+ raw = os.environ.get("OUTERLOOP_MAX_JOB_MINUTES", "").strip()
2952
3147
  if not raw:
2953
3148
  return MAX_ATTEMPT_JOB_MINUTES
2954
3149
  try:
2955
3150
  value = int(raw)
2956
3151
  except ValueError:
2957
- 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)
2958
3153
  return MAX_ATTEMPT_JOB_MINUTES
2959
3154
  clamped = max(ATTEMPT_JOB_MINUTES_FLOOR, min(value, MAX_JOB_MINUTES_CEILING))
2960
3155
  if clamped != value:
2961
- 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)
2962
3157
  return clamped
2963
3158
 
2964
3159
 
2965
3160
  def _cadence_s() -> float:
2966
- """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
2967
3162
  knob tick_chain.sbatch uses), defaulting to 30 min when unset/invalid."""
2968
- raw = os.environ.get("AUTORESEARCH_CADENCE_MIN", "").strip()
3163
+ raw = os.environ.get("OUTERLOOP_CADENCE_MIN", "").strip()
2969
3164
  try:
2970
3165
  cadence_s = float(raw) * 60 if raw else 30 * 60
2971
3166
  except ValueError:
@@ -2975,7 +3170,7 @@ def _cadence_s() -> float:
2975
3170
 
2976
3171
  def _coalesce_ceiling_s() -> float:
2977
3172
  """The largest SAFE coalesce window, bounding both the default and an
2978
- 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
2979
3174
  tick is never coalesced even when the previous one ran a little late), and
2980
3175
  never above the absolute MAX_MIN_TICK_S. A window at/above the cadence would
2981
3176
  swallow every normal tick and stall the loop — this is what forbids it."""
@@ -2991,29 +3186,29 @@ def _default_min_tick_s() -> float:
2991
3186
 
2992
3187
 
2993
3188
  def _min_tick_s_from_env() -> float:
2994
- """AUTORESEARCH_MIN_TICK_MINUTES -> the coalesce window in seconds. Unset
3189
+ """OUTERLOOP_MIN_TICK_MINUTES -> the coalesce window in seconds. Unset
2995
3190
  derives a cadence-aware default; non-numeric/non-finite also fall back to it;
2996
3191
  negative clamps to 0 (coalesce disabled); a value at/above the safe ceiling
2997
3192
  (half the cadence, capped at MAX_MIN_TICK_S) clamps down so it cannot stall
2998
3193
  the loop."""
2999
- raw = os.environ.get("AUTORESEARCH_MIN_TICK_MINUTES", "").strip()
3194
+ raw = os.environ.get("OUTERLOOP_MIN_TICK_MINUTES", "").strip()
3000
3195
  if not raw:
3001
3196
  return _default_min_tick_s()
3002
3197
  try:
3003
3198
  minutes = float(raw)
3004
3199
  except ValueError:
3005
- 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)
3006
3201
  return _default_min_tick_s()
3007
3202
  # reject inf/nan: an infinite window would coalesce every future tick and
3008
3203
  # freeze the loop (a finite elapsed time is always < inf)
3009
3204
  if not math.isfinite(minutes):
3010
- 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)
3011
3206
  return _default_min_tick_s()
3012
3207
  seconds = max(0.0, minutes * 60)
3013
3208
  ceiling = _coalesce_ceiling_s()
3014
3209
  if seconds > ceiling:
3015
3210
  log.warning(
3016
- "AUTORESEARCH_MIN_TICK_MINUTES=%s exceeds the safe ceiling "
3211
+ "OUTERLOOP_MIN_TICK_MINUTES=%s exceeds the safe ceiling "
3017
3212
  "(%.0f min, ~half the cadence); clamping so normal ticks are not coalesced",
3018
3213
  raw,
3019
3214
  ceiling / 60,
@@ -3022,25 +3217,41 @@ def _min_tick_s_from_env() -> float:
3022
3217
  return seconds
3023
3218
 
3024
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
+
3025
3229
  def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
3026
3230
  """GitHub client + FollowupSpec from the chain environment, or Nones when
3027
3231
  the environment is incomplete (the tick then runs without in-review
3028
3232
  servicing, and logs what is absent)."""
3029
- pat_file = os.environ.get("AUTORESEARCH_PAT_FILE", "")
3030
- app_file = os.environ.get("AUTORESEARCH_GITHUB_APP_FILE", "")
3031
- account = os.environ.get("AUTORESEARCH_ACCOUNT", "")
3032
- partition = os.environ.get("AUTORESEARCH_PARTITION", "")
3033
- image = os.environ.get(
3034
- "AUTORESEARCH_IMAGE",
3035
- os.path.expanduser("~/autoresearch-images/agent-py312.sif"),
3036
- )
3037
- home = os.environ.get("AUTORESEARCH_HOME", "")
3038
- # Local compute runs jobs as subprocesses: Slurm placement is meaningless
3039
- # there, so account/partition are only required when a cluster is in play.
3040
- placed = bool(account and partition) or local_mode()
3041
- target = os.environ.get("AUTORESEARCH_TARGET", "")
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
+ )
3042
3253
  image_ok = Path(image).is_file()
3043
- panel = os.environ.get("AUTORESEARCH_PANEL", "verify,review")
3254
+ panel = os.environ.get("OUTERLOOP_PANEL", "verify,review")
3044
3255
  if not image_ok and local_mode():
3045
3256
  # The local loop on a machine with no container: sessions run under
3046
3257
  # the harness's own sandbox and evaluations run bare, on the
@@ -3056,16 +3267,16 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
3056
3267
  "sandbox and evaluations run bare on this machine; the panel is %s; a "
3057
3268
  "codex author needs the image (docs/install.md, local mode)",
3058
3269
  image,
3059
- "on by AUTORESEARCH_PANEL_UNCONTAINED=1"
3060
- if os.environ.get("AUTORESEARCH_PANEL_UNCONTAINED") == "1"
3061
- else "off (AUTORESEARCH_PANEL_UNCONTAINED=1 turns it on)",
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)",
3062
3273
  )
3063
- if os.environ.get("AUTORESEARCH_PANEL_UNCONTAINED") != "1":
3274
+ if os.environ.get("OUTERLOOP_PANEL_UNCONTAINED") != "1":
3064
3275
  panel = ""
3065
3276
  # the jobs must not inherit a path to an image that is not there
3066
- os.environ.pop("AUTORESEARCH_IMAGE", None)
3277
+ os.environ.pop("OUTERLOOP_IMAGE", None)
3067
3278
  image, image_ok = "", True
3068
- if (pat_file or app_file) and placed and home and target and image_ok:
3279
+ if (pat_file or app_file) and home and target and image_ok:
3069
3280
  from outerloop.appauth import resolve_bot_auth
3070
3281
  from outerloop.github import GitHubClient
3071
3282
 
@@ -3080,13 +3291,14 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
3080
3291
  pat_file=pat_file,
3081
3292
  github_app_file=app_file,
3082
3293
  target=target,
3083
- steward_key_file=os.environ.get("AUTORESEARCH_STEWARD_KEY_FILE", ""),
3294
+ steward_key_file=os.environ.get("OUTERLOOP_STEWARD_KEY_FILE", ""),
3084
3295
  panel=panel,
3085
- panel_key_file=os.environ.get("AUTORESEARCH_PANEL_KEY_FILE", ""),
3086
- job_partition=os.environ.get("AUTORESEARCH_JOB_PARTITION", ""),
3087
- gpu_partition=os.environ.get("AUTORESEARCH_GPU_PARTITION", ""),
3088
- gpu_account=os.environ.get("AUTORESEARCH_GPU_ACCOUNT", ""),
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", ""),
3089
3300
  max_job_minutes=_max_job_minutes_from_env(),
3301
+ max_launch_gpus=max_launch_gpus_from_env(),
3090
3302
  )
3091
3303
  return github, followup_spec
3092
3304
  except Exception as exc:
@@ -3095,11 +3307,9 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
3095
3307
  absent = [
3096
3308
  name
3097
3309
  for name, value in [
3098
- ("AUTORESEARCH_PAT_FILE or _GITHUB_APP_FILE", pat_file or app_file),
3099
- ("AUTORESEARCH_ACCOUNT", account or ("-" if local_mode() else "")),
3100
- ("AUTORESEARCH_PARTITION", partition or ("-" if local_mode() else "")),
3101
- ("AUTORESEARCH_HOME", home),
3102
- ("AUTORESEARCH_TARGET", target),
3310
+ ("OUTERLOOP_PAT_FILE or _GITHUB_APP_FILE", pat_file or app_file),
3311
+ ("OUTERLOOP_HOME", home),
3312
+ ("OUTERLOOP_TARGET", target),
3103
3313
  ]
3104
3314
  if not value
3105
3315
  ]
@@ -3112,7 +3322,7 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
3112
3322
  def _loop_cadence_s(cadence_min: float) -> float:
3113
3323
  """The --loop sleep, clamped to [60s, 24h]: argparse accepts inf (which
3114
3324
  would OverflowError out of time.sleep) and sub-minute values would spin.
3115
- A non-positive argument defers to AUTORESEARCH_CADENCE_MIN."""
3325
+ A non-positive argument defers to OUTERLOOP_CADENCE_MIN."""
3116
3326
  return min(24 * 3600.0, max(60.0, cadence_min * 60 if cadence_min > 0 else _cadence_s()))
3117
3327
 
3118
3328
 
@@ -3120,15 +3330,30 @@ def main() -> int:
3120
3330
  import argparse
3121
3331
  import time
3122
3332
 
3123
- 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
+ )
3124
3339
  parser.add_argument("--root", required=True, type=Path, help="state root on the shared FS")
3125
- parser.add_argument("--grace-s", type=float, default=DEFAULT_GRACE_S)
3126
- 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
+ )
3127
3352
  parser.add_argument(
3128
3353
  "--min-free-gb",
3129
3354
  type=float,
3130
3355
  default=DEFAULT_MIN_FREE_BYTES / 1024**3,
3131
- 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",
3132
3357
  )
3133
3358
  parser.add_argument(
3134
3359
  "--loop",
@@ -3141,23 +3366,23 @@ def main() -> int:
3141
3366
  type=float,
3142
3367
  default=0.0,
3143
3368
  help="minutes between --loop ticks; unset defers to "
3144
- "AUTORESEARCH_CADENCE_MIN via the chain's own parser (default 30)",
3369
+ "OUTERLOOP_CADENCE_MIN via the chain's own parser (default 30)",
3145
3370
  )
3146
3371
  args = parser.parse_args()
3147
3372
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
3148
3373
 
3149
3374
  args.root.mkdir(parents=True, exist_ok=True)
3150
3375
  # The tick's --root is the authority; children (and this process's own
3151
- # LocalCompute) read AUTORESEARCH_ROOT, so a bare `tick --loop --root X`
3376
+ # LocalCompute) read OUTERLOOP_ROOT, so a bare `tick --loop --root X`
3152
3377
  # must not split-brain them: local job states would land nowhere and
3153
3378
  # every finished job would read GONE until the park deadline.
3154
3379
  # RESOLVED: local jobs cd into flight checkouts, so a relative root
3155
3380
  # would scatter their state dirs across working directories
3156
- if os.environ.get("AUTORESEARCH_ROOT", "") != str(args.root.resolve()):
3157
- 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())
3158
3383
  # In-review servicing is LIVE when credentials + image are available in the
3159
3384
  # chain environment. The waiting-run sweep delivers real wakes only when the
3160
- # operator arms it — the AUTORESEARCH_DISPATCH_WAKE env var or a
3385
+ # operator arms it — the OUTERLOOP_DISPATCH_WAKE env var or a
3161
3386
  # <root>/DISPATCH_WAKE sentinel — and the env is complete; by default it
3162
3387
  # stays dry with the LoggingDispatcher — dispatched climbing lands DARK.
3163
3388
  # ONE compute for the process: LocalCompute remembers its jobs' states