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 +1 -1
- outerloop/appmanifest.py +15 -10
- outerloop/attempt.py +29 -7
- outerloop/cli.py +24 -16
- outerloop/climbboard.py +209 -4
- outerloop/compute.py +27 -0
- outerloop/followup.py +24 -7
- outerloop/init.py +192 -19
- outerloop/paths.py +12 -0
- outerloop/tick.py +107 -37
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev1.dist-info}/METADATA +13 -9
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev1.dist-info}/RECORD +16 -16
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev1.dist-info}/WHEEL +0 -0
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev1.dist-info}/entry_points.txt +0 -0
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev1.dist-info}/licenses/LICENSE +0 -0
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev1.dist-info}/licenses/NOTICE +0 -0
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,
|
|
4
|
-
`~/.config/outerloop/.env` (plus the credential
|
|
5
|
-
hand-edits config or reasons about which
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
194
|
-
print(f"
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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
|
|
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
|