outerloop-science 0.1.0.dev0__py3-none-any.whl → 0.1.0.dev1__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 CHANGED
@@ -1,8 +1,9 @@
1
1
  """`outerloop init` — the guided setup.
2
2
 
3
- Collects placement (Slurm or local), the target repo, and bot auth, then writes
4
- `~/.config/outerloop/.env` (plus the credential files), so a new adopter never
5
- hand-edits config or reasons about which `OUTERLOOP_*` keys to set. Flags fill
3
+ Collects placement (Slurm or local), the target repo, bot auth, and the
4
+ author's model key, then writes `~/.config/outerloop/.env` (plus the credential
5
+ files), so a new adopter never hand-edits config or reasons about which
6
+ `OUTERLOOP_*` keys to set. Flags fill
6
7
  answers non-interactively; anything left out is prompted for (a secret via
7
8
  getpass, never echoed). Auth is the adopter's own GitHub App by default —
8
9
  `--github-app`, one click via the manifest flow in `appmanifest.py` — with a PAT
@@ -18,13 +19,17 @@ from __future__ import annotations
18
19
  import argparse
19
20
  import getpass
20
21
  import json
22
+ import os
21
23
  import sys
24
+ import time
22
25
  import urllib.error
23
26
  import urllib.request
24
27
  from dataclasses import dataclass
25
28
  from pathlib import Path
29
+ from typing import Any
26
30
 
27
31
  from outerloop.cli import ENV_FILE
32
+ from outerloop.paths import write_private
28
33
 
29
34
  CONFIG_DIR = ENV_FILE.parent
30
35
  DEFAULT_PAT_FILE = CONFIG_DIR / "bot_pat"
@@ -42,9 +47,12 @@ class InitAnswers:
42
47
  partition: str = "" # Slurm partition (optional; unset -> Slurm default)
43
48
  author_backend: str = "" # optional: the climbing author's harness
44
49
  author_model: str = "" # optional
50
+ author_key_file: str = "" # the author's model key file, when known
45
51
 
46
52
 
47
- def render_env(a: InitAnswers, pat_file: str = "", *, app_file: str = "") -> str:
53
+ def render_env(
54
+ a: InitAnswers, pat_file: str = "", *, app_file: str = "", bot_login: str = ""
55
+ ) -> str:
48
56
  """The `.env` body for these answers — only the keys that have a value, so
49
57
  the file stays minimal and every line means something. Ordered placement →
50
58
  target → auth → author to read top-to-bottom like the setup itself. Auth is
@@ -60,13 +68,39 @@ def render_env(a: InitAnswers, pat_file: str = "", *, app_file: str = "") -> str
60
68
  lines.append(f"OUTERLOOP_GITHUB_APP_FILE={app_file}")
61
69
  elif pat_file:
62
70
  lines.append(f"OUTERLOOP_PAT_FILE={pat_file}")
71
+ if bot_login:
72
+ # every own-comment filter and own-PR scan keys on this login; without
73
+ # it the kernel assumes a default that is not this adopter's identity
74
+ lines.append(f"OUTERLOOP_BOT_LOGIN={bot_login}")
63
75
  if a.author_backend:
64
76
  lines.append(f"OUTERLOOP_AUTHOR_BACKEND={a.author_backend}")
65
77
  if a.author_model:
66
78
  lines.append(f"OUTERLOOP_AUTHOR_MODEL={a.author_model}")
79
+ if a.author_key_file:
80
+ lines.append(f"{author_key_env(a.author_backend)}={a.author_key_file}")
67
81
  return "\n".join(lines) + "\n"
68
82
 
69
83
 
84
+ def author_key_env(backend: str) -> str:
85
+ """The `.env` key naming `backend`'s model-key file: one rule for every
86
+ backend (`OUTERLOOP_CLAUDE_KEY_FILE`, `OUTERLOOP_CODEX_KEY_FILE`)."""
87
+ return f"OUTERLOOP_{(backend or AUTHOR_BACKENDS[0]).upper()}_KEY_FILE"
88
+
89
+
90
+ def author_key_path(backend: str, *, config_dir: Path = CONFIG_DIR) -> Path:
91
+ """Where `backend`'s model key lives by default: `<config dir>/<backend>_key`,
92
+ the same rule `attempt.resolve_author_key_file` reads."""
93
+ return config_dir / f"{backend or AUTHOR_BACKENDS[0]}_key"
94
+
95
+
96
+ def write_author_key(backend: str, key: str, *, config_dir: Path = CONFIG_DIR) -> Path:
97
+ """Write a pasted model key to its default file, owner-only (0600), no
98
+ trailing newline. Returns the path."""
99
+ path = author_key_path(backend, config_dir=config_dir)
100
+ write_private(path, key)
101
+ return path
102
+
103
+
70
104
  def write_config(
71
105
  a: InitAnswers, token: str, pat_file: str, *, config_dir: Path = CONFIG_DIR
72
106
  ) -> tuple[Path, Path | None]:
@@ -79,13 +113,11 @@ def write_config(
79
113
  pat_path = config_dir / DEFAULT_PAT_FILE.name
80
114
  # printf-style: no trailing newline — the deploy `cat`s this file into
81
115
  # the git credential, and a trailing newline rides along and breaks auth.
82
- pat_path.write_text(token)
83
- pat_path.chmod(0o600)
116
+ write_private(pat_path, token)
84
117
  written_pat = pat_path
85
118
  pat_file = str(pat_path)
86
119
  env_path = config_dir / ENV_FILE.name
87
- env_path.write_text(render_env(a, pat_file))
88
- env_path.chmod(0o600)
120
+ write_private(env_path, render_env(a, pat_file))
89
121
  return env_path, written_pat
90
122
 
91
123
 
@@ -111,6 +143,55 @@ def _check_repo_access(token: str, target: str) -> str:
111
143
  return f"could not reach GitHub: {exc.reason}"
112
144
 
113
145
 
146
+ def _check_app_access(provider: Any, target: str) -> str:
147
+ """Can this App write the target repo? "" on success, else a short reason.
148
+
149
+ An installation token does not populate a repository's `permissions`
150
+ object, so the PAT check above reads all-false for an App that can push.
151
+ The App is asked the two questions that decide it: is the target among
152
+ the repositories its installation covers, and does the installation carry
153
+ write on contents and pull requests. Never raises."""
154
+ from outerloop.appauth import build_app_jwt
155
+
156
+ headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
157
+ try:
158
+ # Asked as the App itself: GitHub answers with the installation that
159
+ # covers this repository, permissions included, or 404. A public
160
+ # repository would answer a plain read for any token, and the
161
+ # installation's repository list paginates, so neither is asked.
162
+ jwt = build_app_jwt(provider.app_id, time.time(), provider._sign)
163
+ req = urllib.request.Request(
164
+ f"{API}/repos/{target}/installation",
165
+ headers={**headers, "Authorization": f"Bearer {jwt}"},
166
+ )
167
+ try:
168
+ with urllib.request.urlopen(req, timeout=15) as resp:
169
+ installation = json.loads(resp.read())
170
+ except urllib.error.HTTPError as exc:
171
+ if exc.code == 404:
172
+ return f"the App is not installed on {target}; install it there"
173
+ raise
174
+ if int(installation.get("id", 0)) != int(provider.installation_id):
175
+ return (
176
+ f"the App is installed on {target} under installation "
177
+ f"{installation.get('id')}, but the App file records {provider.installation_id}"
178
+ )
179
+ perms = installation.get("permissions") or {}
180
+ missing = [k for k in ("contents", "issues", "pull_requests") if perms.get(k) != "write"]
181
+ if missing:
182
+ return (
183
+ f"the App lacks write on {', '.join(missing)} "
184
+ "(it pushes branches, opens PRs, and files and closes issues)"
185
+ )
186
+ return ""
187
+ except urllib.error.HTTPError as exc:
188
+ return f"GitHub returned {exc.code} while checking the App"
189
+ except urllib.error.URLError as exc:
190
+ return f"could not reach GitHub: {exc.reason}"
191
+ except Exception as exc: # a check failure is a warning, never fails setup
192
+ return f"could not check the App: {exc}"
193
+
194
+
114
195
  def validate_pat(pat_file: str, target: str) -> str:
115
196
  """Best-effort: can the PAT in `pat_file` write the target repo?"""
116
197
  try:
@@ -170,6 +251,26 @@ def _collect(args: argparse.Namespace, interactive: bool) -> tuple[InitAnswers,
170
251
  return answers, (args.pat_file or "")
171
252
 
172
253
 
254
+ ORG_PROMPT = (
255
+ "Create the App under which account? (the repository's owner is the only one "
256
+ "the App can then be installed on; Enter to accept)"
257
+ )
258
+
259
+
260
+ def _owner_type(owner: str) -> str:
261
+ """ "User" or "Organization" for a GitHub account, or "" when the lookup
262
+ fails. GitHub creates Apps at different pages for the two, and a wrong
263
+ guess sends the adopter to a page that cannot create the App."""
264
+ req = urllib.request.Request(
265
+ f"{API}/users/{owner}", headers={"Accept": "application/vnd.github+json"}
266
+ )
267
+ try:
268
+ with urllib.request.urlopen(req, timeout=15) as resp:
269
+ return str(json.loads(resp.read()).get("type", ""))
270
+ except Exception:
271
+ return ""
272
+
273
+
173
274
  def _github_app_setup(answers: InitAnswers, app_name: str, org: str) -> int:
174
275
  """The `--github-app` path: create the adopter's own App via the manifest
175
276
  flow, write its creds, help install it, then point the .env at the App file.
@@ -190,10 +291,26 @@ def _github_app_setup(answers: InitAnswers, app_name: str, org: str) -> int:
190
291
  print(f"outerloop init: {exc}", file=sys.stderr)
191
292
  return 1
192
293
  pem_path, app_json = appmanifest.save_app_creds(conversion, CONFIG_DIR)
193
- print(f"created App '{conversion['slug']}' — wrote {app_json} and {pem_path} (0600)")
194
- print(f"install it on {answers.target}: {appmanifest.install_url(conversion)}")
195
- input("press Enter once you've installed the App… ")
294
+ repo = answers.target.split("/", 1)[-1]
295
+ print(f" created App '{conversion['slug']}'; credentials in {app_json} and {pem_path} (0600)")
296
+ print()
297
+ print(f"Step 2 of 3: install the App on {answers.target}.")
298
+ print(f" Open {appmanifest.install_url(conversion)}")
299
+ print(f" choose 'Only select repositories', pick '{repo}', and click 'Install'.")
300
+ input("Press Enter here once GitHub shows the App as installed… ")
301
+ print()
302
+ print(f"Step 3 of 3: checking that the App can write {answers.target}.")
196
303
  iid = appmanifest.capture_installation_id(int(conversion["id"]), pem_path, owner)
304
+ for _ in range(2):
305
+ if iid:
306
+ break
307
+ # not installed yet: a rerun of init would stop at the existing config,
308
+ # so ask again here rather than sending the adopter around the loop
309
+ print(
310
+ f" no installation found yet. Install the App at {appmanifest.install_url(conversion)}"
311
+ )
312
+ input("Press Enter once GitHub shows the App as installed… ")
313
+ iid = appmanifest.capture_installation_id(int(conversion["id"]), pem_path, owner)
197
314
  if iid:
198
315
  appmanifest.set_installation_id(app_json, iid)
199
316
  print(f" installation id {iid} recorded")
@@ -202,19 +319,23 @@ def _github_app_setup(answers: InitAnswers, app_name: str, org: str) -> int:
202
319
  from outerloop.appauth import app_provider_from_file
203
320
 
204
321
  try:
205
- token = app_provider_from_file(app_json).token()
206
- problem = _check_repo_access(token, answers.target)
207
- print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
322
+ problem = _check_app_access(app_provider_from_file(app_json), answers.target)
208
323
  except Exception as exc: # a check failure is a warning, never fails setup
209
- print(f" auth check: WARNING — could not mint a token: {exc}")
324
+ problem = f"could not read the App credentials: {exc}"
325
+ print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
210
326
  else:
211
327
  print(
212
- f" no installation found yet — install the App, then set installation_id in {app_json}"
328
+ f" still no installation. When it is installed, put its id into {app_json} as "
329
+ f"installation_id (GitHub shows it in the URL of the App's page under "
330
+ f"Settings > Installations), then run outerloop start."
213
331
  )
214
332
  env_path = CONFIG_DIR / ENV_FILE.name
215
- env_path.write_text(render_env(answers, app_file=str(app_json)))
216
- env_path.chmod(0o600)
333
+ write_private(
334
+ env_path,
335
+ render_env(answers, app_file=str(app_json), bot_login=f"{conversion['slug']}[bot]"),
336
+ )
217
337
  print(f"wrote {env_path}")
338
+ _author_key_hint(answers)
218
339
  print("next: outerloop start")
219
340
  return 0
220
341
 
@@ -243,6 +364,11 @@ def main(argv: list[str] | None = None) -> int:
243
364
  parser.add_argument("--org", help="create the App under this org (default: your account)")
244
365
  parser.add_argument("--author-backend", dest="author_backend", help="climbing author's backend")
245
366
  parser.add_argument("--author-model", dest="author_model", help="climbing author's model")
367
+ parser.add_argument(
368
+ "--author-key-file",
369
+ dest="author_key_file",
370
+ help="path to an existing file holding the author's model API key",
371
+ )
246
372
  parser.add_argument(
247
373
  "--yes", "-y", action="store_true", help="non-interactive: use flags, do not prompt"
248
374
  )
@@ -280,6 +406,30 @@ def main(argv: list[str] | None = None) -> int:
280
406
  )
281
407
  return 1
282
408
 
409
+ # The author's model key: an existing file by flag, or (on the full
410
+ # interactive run, never the focused --github-app one) a hidden paste written
411
+ # to <config>/<backend>_key. Collected after the overwrite check so a
412
+ # declined run never asks for a secret.
413
+ if args.author_key_file:
414
+ key_file = Path(args.author_key_file).expanduser()
415
+ if not key_file.is_file() or not os.access(key_file, os.R_OK):
416
+ print(
417
+ f"outerloop init: --author-key-file {key_file} is not a readable file",
418
+ file=sys.stderr,
419
+ )
420
+ return 2
421
+ # absolute: climb jobs read the key from their own flight directory
422
+ answers.author_key_file = str(key_file.absolute())
423
+ if not answers.author_key_file and interactive and not args.github_app:
424
+ pasted = getpass.getpass(
425
+ f"API key for the {answers.author_backend or AUTHOR_BACKENDS[0]} author "
426
+ "(hidden; blank to set later): "
427
+ ).strip()
428
+ if pasted:
429
+ key_path = write_author_key(answers.author_backend, pasted, config_dir=CONFIG_DIR)
430
+ answers.author_key_file = str(key_path)
431
+ print(f"wrote {key_path} (0600)")
432
+
283
433
  # The App is the recommended credential (scoped, revocable, no plaintext
284
434
  # token); the PAT is the fallback. Offer it first when interactive.
285
435
  if not args.github_app and not pat_file and interactive:
@@ -290,7 +440,20 @@ def main(argv: list[str] | None = None) -> int:
290
440
  if choice.startswith("a"):
291
441
  args.github_app = True
292
442
  if args.github_app:
293
- return _github_app_setup(answers, args.app_name or "", args.org or "")
443
+ org = args.org or ""
444
+ if not org and interactive:
445
+ # a private App installs only on the account that created it, so
446
+ # the target's owner is the only default that can work
447
+ owner = answers.target.split("/")[0]
448
+ org = _ask(ORG_PROMPT, owner).strip()
449
+ if org:
450
+ kind = _owner_type(org)
451
+ if not kind and interactive:
452
+ answer = _ask(f"Is '{org}' an organization or a user account? [org/user]", "org")
453
+ kind = "User" if answer.strip().lower().startswith("u") else "Organization"
454
+ if kind == "User":
455
+ org = "" # a user account: GitHub's personal App page, not an org page
456
+ return _github_app_setup(answers, args.app_name or "", org)
294
457
 
295
458
  token = ""
296
459
  if not pat_file and interactive:
@@ -309,5 +472,15 @@ def main(argv: list[str] | None = None) -> int:
309
472
  print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
310
473
  else:
311
474
  print(" no PAT set — add OUTERLOOP_PAT_FILE before the agents can open PRs")
475
+ _author_key_hint(answers)
312
476
  print("next: outerloop start")
313
477
  return 0
478
+
479
+
480
+ def _author_key_hint(answers: InitAnswers) -> None:
481
+ if not answers.author_key_file:
482
+ print(
483
+ " no author key set — put it in "
484
+ f"{author_key_path(answers.author_backend, config_dir=CONFIG_DIR)} "
485
+ "before the first climb"
486
+ )
outerloop/paths.py CHANGED
@@ -9,6 +9,7 @@ dir if it exists, else the legacy dir if it exists, else the new dir.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import os
12
13
  from pathlib import Path
13
14
 
14
15
  CONFIG_DIR_NAMES: tuple[str, ...] = ("outerloop", "autoresearch") # new first
@@ -25,3 +26,14 @@ def config_dir(home: Path | None = None) -> Path:
25
26
 
26
27
  CONFIG_DIR = config_dir()
27
28
  ENV_FILE = CONFIG_DIR / ".env"
29
+
30
+
31
+ def write_private(path: Path, text: str) -> None:
32
+ """Write `text` to `path` so no other user can read it at any moment: the
33
+ file is created (or truncated) with mode 0600 in the same call, and a file
34
+ that already existed has its mode forced to 0600 before the write."""
35
+ path.parent.mkdir(parents=True, exist_ok=True)
36
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
37
+ with os.fdopen(fd, "w") as fh:
38
+ os.fchmod(fd, 0o600)
39
+ fh.write(text)
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
@@ -234,6 +235,10 @@ def flight_checkout(home: Path, name: str, now: float) -> Path:
234
235
  uncommitted hand-edits in the shared checkout do not fly — only
235
236
  deployed code does. Failures fall back to the shared checkout — a
236
237
  snapshot must never ground the fleet."""
238
+ if not (home / ".git").exists():
239
+ # not a checkout (the local loop on an installed package): nothing to
240
+ # pin, the job runs from the home directory itself
241
+ return home
237
242
  flights = home.parent / "flights"
238
243
  try:
239
244
  flights.mkdir(parents=True, exist_ok=True)
@@ -286,6 +291,25 @@ def _gpu_lane_error(contract: Any, benchmark: str, spec: FollowupSpec) -> str:
286
291
  return ""
287
292
 
288
293
 
294
+ _UNCONTAINED_WARNED = False # the local-mode warning is said once per process
295
+
296
+
297
+ def _containment(image: str) -> list[str]:
298
+ """The job's containment flags: the image when there is one, else the
299
+ explicit uncontained flag every entry point requires in its absence."""
300
+ return ["--image", image] if image else ["--uncontained"]
301
+
302
+
303
+ def _interpreter(home: Path) -> list[str]:
304
+ """How a job runs Python. From a source checkout, `uv run python` resolves
305
+ the project's own environment, as the cluster's flights do. From a plain
306
+ home directory (the local loop on an installed package) the job runs the
307
+ interpreter this tick runs under, which is where the package is."""
308
+ if (home / "pyproject.toml").is_file():
309
+ return ["uv", "run", "python"]
310
+ return [sys.executable]
311
+
312
+
289
313
  def _flight_command(home: Path, job_name: str, now: float, argv: list[str]) -> str:
290
314
  """The job's shell command, cd'ing into a fresh flight snapshot.
291
315
 
@@ -682,8 +706,29 @@ def service_in_review(
682
706
  except SlurmQueryError:
683
707
  continue # unknown — do not stack another job
684
708
  # 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:
709
+ # that cannot advance its cursors must not burn a session per tick.
710
+ # A LANDED re-measure gets its own allowance: the sessions that
711
+ # synced the base and dispatched it were progress, and capping the
712
+ # follow-up that pushes the sealed number parks the result forever
713
+ # (speedrun agent-03, 2026-09-04: three syncs spent the cap, the
714
+ # measure completed, and the run idled for two days on this
715
+ # branch). The allowance is counted on the stage itself, so a
716
+ # finishing session that reverts and leaves the stage intact is
717
+ # retried MAX_WAKE_ATTEMPTS times, not once per tick; a stage that
718
+ # finishes is cleared, and with it the count.
719
+ finish_attempts = (
720
+ int(record.followup_stage.get("finish_attempts", 0) or 0) # type: ignore[call-overload]
721
+ if measure_ready
722
+ else 0
723
+ )
724
+ if measure_ready and finish_attempts >= MAX_WAKE_ATTEMPTS:
725
+ log.warning(
726
+ "run %s: %d follow-ups failed to finish the landed re-measure; parked",
727
+ record.run_id,
728
+ finish_attempts,
729
+ )
730
+ continue
731
+ if record.wake_attempts >= MAX_WAKE_ATTEMPTS and not measure_ready:
687
732
  log.warning(
688
733
  "run %s: %d follow-up attempts without progress; not resubmitting",
689
734
  record.run_id,
@@ -725,17 +770,14 @@ def service_in_review(
725
770
  if panel_argv:
726
771
  panel_argv = [*panel_argv, "--panel-minutes", str(job_minutes - author_minutes)]
727
772
  argv = [
728
- "uv",
729
- "run",
730
- "python",
773
+ *_interpreter(spec.home),
731
774
  "-m",
732
775
  "outerloop.followup",
733
776
  "--run-root",
734
777
  str(spec.run_root),
735
778
  "--run-id",
736
779
  record.run_id,
737
- "--image",
738
- spec.image,
780
+ *_containment(spec.image),
739
781
  "--bot-login",
740
782
  spec.bot_login,
741
783
  "--job-minutes",
@@ -777,11 +819,16 @@ def service_in_review(
777
819
  # read-modify-write on the FRESH record: the submitted job may
778
820
  # already be saving its own fields
779
821
  latest = load_record(root, record.run_id)
822
+ stage = latest.followup_stage
823
+ if measure_ready and stage:
824
+ # one finishing attempt billed against this landed measure
825
+ stage = {**stage, "finish_attempts": finish_attempts + 1}
780
826
  save_record(
781
827
  root,
782
828
  replace(
783
829
  latest,
784
830
  followup_job_id=job_id,
831
+ followup_stage=stage,
785
832
  wake_attempts=latest.wake_attempts + 1,
786
833
  ),
787
834
  now,
@@ -1790,7 +1837,7 @@ def tick(
1790
1837
  log.warning("self-initiated selection failed: %s", exc)
1791
1838
  # AFTER the launch block: a run started this tick is on the strip
1792
1839
  # this tick, not the next one
1793
- service_boards(root, github, spec.target, contract, now)
1840
+ service_boards(root, github, spec.target, contract, now, compute)
1794
1841
  report = replace_report(
1795
1842
  report,
1796
1843
  ended,
@@ -1844,10 +1891,13 @@ def service_syncs(root: Path, spec: Any, now: float) -> None:
1844
1891
  log.warning("sync failed for %s: %s", record.run_id, exc)
1845
1892
 
1846
1893
 
1847
- def service_boards(root: Path, github: Any, target: str, contract: Any, now: float) -> None:
1894
+ def service_boards(
1895
+ root: Path, github: Any, target: str, contract: Any, now: float, compute: Any = None
1896
+ ) -> None:
1848
1897
  """The climb board and the live strip, together and advisory: the views
1849
1898
  publish from the first tick (before any run ends), and a failure never
1850
- stops the tick."""
1899
+ stops the tick. With a compute backend, the strip also carries the
1900
+ kernel's queue (its own Slurm jobs, attributed to agents)."""
1851
1901
  try:
1852
1902
  from outerloop.climbboard import contract_directions, service_climb_board
1853
1903
 
@@ -1857,7 +1907,13 @@ def service_boards(root: Path, github: Any, target: str, contract: Any, now: flo
1857
1907
  try:
1858
1908
  from outerloop.climbboard import service_status
1859
1909
 
1860
- service_status(root, github, target, now, contract)
1910
+ queue = None
1911
+ if compute is not None:
1912
+ try:
1913
+ queue = compute.queue_snapshot()
1914
+ except Exception as exc: # blind this tick: the strip publishes without a queue
1915
+ log.warning("queue snapshot failed: %s", exc)
1916
+ service_status(root, github, target, now, contract, queue=queue)
1861
1917
  except Exception as exc: # each is advisory ALONE: one failing never mutes the other
1862
1918
  log.warning("status strip service failed: %s", exc)
1863
1919
 
@@ -2436,9 +2492,7 @@ def service_self_initiated(
2436
2492
  return (benchmark, "dry-run")
2437
2493
  job_minutes = _attempt_job_minutes(spec, limits)
2438
2494
  argv = [
2439
- "uv",
2440
- "run",
2441
- "python",
2495
+ *_interpreter(spec.home),
2442
2496
  "-m",
2443
2497
  "outerloop.attempt",
2444
2498
  "--target",
@@ -2447,8 +2501,7 @@ def service_self_initiated(
2447
2501
  benchmark,
2448
2502
  "--run-root",
2449
2503
  str(spec.run_root),
2450
- "--image",
2451
- spec.image,
2504
+ *_containment(spec.image),
2452
2505
  "--agent-id",
2453
2506
  slot_agent,
2454
2507
  *_climb_limit_argv(limits, job_minutes),
@@ -2565,9 +2618,7 @@ def service_steward(
2565
2618
 
2566
2619
  work_order_b64 = _b64.b64encode(issue_hypothesis(task).encode()).decode()
2567
2620
  argv = [
2568
- "uv",
2569
- "run",
2570
- "python",
2621
+ *_interpreter(spec.home),
2571
2622
  "-m",
2572
2623
  "outerloop.steward",
2573
2624
  "--target",
@@ -2576,8 +2627,7 @@ def service_steward(
2576
2627
  task.benchmark,
2577
2628
  "--run-root",
2578
2629
  str(spec.run_root),
2579
- "--image",
2580
- spec.image,
2630
+ *_containment(spec.image),
2581
2631
  "--issue",
2582
2632
  str(task.number),
2583
2633
  "--work-order-b64",
@@ -2707,9 +2757,7 @@ def service_intake(
2707
2757
 
2708
2758
  hypothesis_b64 = _b64.b64encode(issue_hypothesis(task).encode()).decode()
2709
2759
  argv = [
2710
- "uv",
2711
- "run",
2712
- "python",
2760
+ *_interpreter(spec.home),
2713
2761
  "-m",
2714
2762
  "outerloop.attempt",
2715
2763
  "--target",
@@ -2718,8 +2766,7 @@ def service_intake(
2718
2766
  task.benchmark,
2719
2767
  "--run-root",
2720
2768
  str(spec.run_root),
2721
- "--image",
2722
- spec.image,
2769
+ *_containment(spec.image),
2723
2770
  "--issue",
2724
2771
  str(task.number),
2725
2772
  "--hypothesis-b64",
@@ -2791,17 +2838,14 @@ class JobWakeDispatcher:
2791
2838
 
2792
2839
  def dispatch(self, record: RunRecord, reason: str) -> str:
2793
2840
  argv = [
2794
- "uv",
2795
- "run",
2796
- "python",
2841
+ *_interpreter(self.spec.home),
2797
2842
  "-m",
2798
2843
  "outerloop.attempt",
2799
2844
  "--resume",
2800
2845
  record.run_id,
2801
2846
  "--run-root",
2802
2847
  str(self.spec.run_root),
2803
- "--image",
2804
- self.spec.image,
2848
+ *_containment(self.spec.image),
2805
2849
  "--account",
2806
2850
  self.spec.account,
2807
2851
  "--partition",
@@ -2994,7 +3038,34 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
2994
3038
  # Local compute runs jobs as subprocesses: Slurm placement is meaningless
2995
3039
  # there, so account/partition are only required when a cluster is in play.
2996
3040
  placed = bool(account and partition) or local_mode()
2997
- if (pat_file or app_file) and placed and home and Path(image).is_file():
3041
+ target = os.environ.get("AUTORESEARCH_TARGET", "")
3042
+ image_ok = Path(image).is_file()
3043
+ panel = os.environ.get("AUTORESEARCH_PANEL", "verify,review")
3044
+ if not image_ok and local_mode():
3045
+ # The local loop on a machine with no container: sessions run under
3046
+ # the harness's own sandbox and evaluations run bare, on the
3047
+ # operator's own machine with the operator's own keys. The panel is
3048
+ # off unless the operator opts in, because an uncontained judge holds
3049
+ # a shell next to its own key file. Said once per process. Contained
3050
+ # local mode needs apptainer and the image (docs/install.md).
3051
+ global _UNCONTAINED_WARNED
3052
+ if not _UNCONTAINED_WARNED:
3053
+ _UNCONTAINED_WARNED = True
3054
+ log.warning(
3055
+ "local mode: no container image at %s; sessions run under the harness "
3056
+ "sandbox and evaluations run bare on this machine; the panel is %s; a "
3057
+ "codex author needs the image (docs/install.md, local mode)",
3058
+ 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)",
3062
+ )
3063
+ if os.environ.get("AUTORESEARCH_PANEL_UNCONTAINED") != "1":
3064
+ panel = ""
3065
+ # the jobs must not inherit a path to an image that is not there
3066
+ os.environ.pop("AUTORESEARCH_IMAGE", None)
3067
+ image, image_ok = "", True
3068
+ if (pat_file or app_file) and placed and home and target and image_ok:
2998
3069
  from outerloop.appauth import resolve_bot_auth
2999
3070
  from outerloop.github import GitHubClient
3000
3071
 
@@ -3008,11 +3079,9 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
3008
3079
  home=Path(home),
3009
3080
  pat_file=pat_file,
3010
3081
  github_app_file=app_file,
3011
- target=os.environ.get(
3012
- "AUTORESEARCH_TARGET", "agentic-learning-ai-lab/autoresearch-pilot"
3013
- ),
3082
+ target=target,
3014
3083
  steward_key_file=os.environ.get("AUTORESEARCH_STEWARD_KEY_FILE", ""),
3015
- panel=os.environ.get("AUTORESEARCH_PANEL", "verify,review"),
3084
+ panel=panel,
3016
3085
  panel_key_file=os.environ.get("AUTORESEARCH_PANEL_KEY_FILE", ""),
3017
3086
  job_partition=os.environ.get("AUTORESEARCH_JOB_PARTITION", ""),
3018
3087
  gpu_partition=os.environ.get("AUTORESEARCH_GPU_PARTITION", ""),
@@ -3030,10 +3099,11 @@ def _followup_spec_from_env(root: Path) -> tuple[Any, FollowupSpec | None]:
3030
3099
  ("AUTORESEARCH_ACCOUNT", account or ("-" if local_mode() else "")),
3031
3100
  ("AUTORESEARCH_PARTITION", partition or ("-" if local_mode() else "")),
3032
3101
  ("AUTORESEARCH_HOME", home),
3102
+ ("AUTORESEARCH_TARGET", target),
3033
3103
  ]
3034
3104
  if not value
3035
3105
  ]
3036
- if not Path(image).is_file():
3106
+ if not image_ok:
3037
3107
  absent.append(f"image:{image}")
3038
3108
  log.info("in-review servicing disabled (missing: %s)", ", ".join(absent))
3039
3109
  return None, None