outerloop-science 0.1.0.dev0__py3-none-any.whl → 0.1.0.dev2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- outerloop/__init__.py +7 -7
- outerloop/appauth.py +3 -3
- outerloop/appmanifest.py +16 -11
- outerloop/attempt.py +80 -52
- outerloop/cli.py +170 -86
- outerloop/climbboard.py +209 -4
- outerloop/compute.py +84 -7
- outerloop/contract.py +3 -2
- outerloop/followup.py +37 -23
- outerloop/github.py +4 -4
- outerloop/harness.py +18 -7
- outerloop/image.py +372 -0
- outerloop/init.py +417 -29
- outerloop/limits.py +1 -1
- outerloop/measure.py +2 -2
- outerloop/orchestrator.py +1 -1
- outerloop/paths.py +14 -1
- outerloop/review.py +1 -1
- outerloop/steward.py +3 -3
- outerloop/syscall.py +1 -0
- outerloop/tick.py +400 -105
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.dist-info}/METADATA +14 -10
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.dist-info}/RECORD +27 -26
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.dist-info}/WHEEL +0 -0
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.dist-info}/entry_points.txt +0 -0
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.dist-info}/licenses/LICENSE +0 -0
- {outerloop_science-0.1.0.dev0.dist-info → outerloop_science-0.1.0.dev2.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,19 @@ from __future__ import annotations
|
|
|
18
19
|
import argparse
|
|
19
20
|
import getpass
|
|
20
21
|
import json
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
21
24
|
import sys
|
|
25
|
+
import time
|
|
22
26
|
import urllib.error
|
|
23
27
|
import urllib.request
|
|
24
28
|
from dataclasses import dataclass
|
|
25
29
|
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
26
31
|
|
|
27
32
|
from outerloop.cli import ENV_FILE
|
|
33
|
+
from outerloop.image import ensure_image
|
|
34
|
+
from outerloop.paths import write_private
|
|
28
35
|
|
|
29
36
|
CONFIG_DIR = ENV_FILE.parent
|
|
30
37
|
DEFAULT_PAT_FILE = CONFIG_DIR / "bot_pat"
|
|
@@ -38,13 +45,19 @@ class InitAnswers:
|
|
|
38
45
|
compute: str # "slurm" | "local"
|
|
39
46
|
target: str # "owner/repo"
|
|
40
47
|
root: str = "" # Slurm state root on the shared filesystem
|
|
41
|
-
account: str = "" # Slurm account (
|
|
48
|
+
account: str = "" # Slurm account (optional; unset -> the default association)
|
|
42
49
|
partition: str = "" # Slurm partition (optional; unset -> Slurm default)
|
|
43
50
|
author_backend: str = "" # optional: the climbing author's harness
|
|
44
51
|
author_model: str = "" # optional
|
|
52
|
+
author_key_file: str = "" # the author's model key file, when known
|
|
53
|
+
image: str = "" # the agent image (OUTERLOOP_IMAGE)
|
|
54
|
+
uncontained: bool = False # --no-image: write OUTERLOOP_IMAGE= so no image is picked up
|
|
55
|
+
author_bin: str = "" # the author harness binary (claude/codex), absolute, when found
|
|
45
56
|
|
|
46
57
|
|
|
47
|
-
def render_env(
|
|
58
|
+
def render_env(
|
|
59
|
+
a: InitAnswers, pat_file: str = "", *, app_file: str = "", bot_login: str = ""
|
|
60
|
+
) -> str:
|
|
48
61
|
"""The `.env` body for these answers — only the keys that have a value, so
|
|
49
62
|
the file stays minimal and every line means something. Ordered placement →
|
|
50
63
|
target → auth → author to read top-to-bottom like the setup itself. Auth is
|
|
@@ -52,21 +65,113 @@ def render_env(a: InitAnswers, pat_file: str = "", *, app_file: str = "") -> str
|
|
|
52
65
|
lines = [f"OUTERLOOP_COMPUTE={a.compute}"]
|
|
53
66
|
if a.compute == "slurm":
|
|
54
67
|
lines.append(f"OUTERLOOP_ROOT={a.root}")
|
|
55
|
-
|
|
68
|
+
if a.account: # optional: unset bills the caller's default association
|
|
69
|
+
lines.append(f"OUTERLOOP_ACCOUNT={a.account}")
|
|
56
70
|
if a.partition: # optional: unset lets Slurm pick its default partition
|
|
57
71
|
lines.append(f"OUTERLOOP_PARTITION={a.partition}")
|
|
72
|
+
if a.image:
|
|
73
|
+
lines.append(f"OUTERLOOP_IMAGE={a.image}")
|
|
74
|
+
elif a.uncontained:
|
|
75
|
+
# the empty value is the off-switch: without it the tick would still
|
|
76
|
+
# pick up an image already at the default path
|
|
77
|
+
lines.append("OUTERLOOP_IMAGE=")
|
|
58
78
|
lines.append(f"OUTERLOOP_TARGET={a.target}")
|
|
59
79
|
if app_file:
|
|
60
80
|
lines.append(f"OUTERLOOP_GITHUB_APP_FILE={app_file}")
|
|
61
81
|
elif pat_file:
|
|
62
82
|
lines.append(f"OUTERLOOP_PAT_FILE={pat_file}")
|
|
83
|
+
if bot_login:
|
|
84
|
+
# every own-comment filter and own-PR scan keys on this login; without
|
|
85
|
+
# it the kernel assumes a default that is not this adopter's identity
|
|
86
|
+
lines.append(f"OUTERLOOP_BOT_LOGIN={bot_login}")
|
|
63
87
|
if a.author_backend:
|
|
64
88
|
lines.append(f"OUTERLOOP_AUTHOR_BACKEND={a.author_backend}")
|
|
65
89
|
if a.author_model:
|
|
66
90
|
lines.append(f"OUTERLOOP_AUTHOR_MODEL={a.author_model}")
|
|
91
|
+
if a.author_key_file:
|
|
92
|
+
lines.append(f"{author_key_env(a.author_backend)}={a.author_key_file}")
|
|
93
|
+
if a.author_bin:
|
|
94
|
+
lines.append(f"{author_bin_env(a.author_backend)}={a.author_bin}")
|
|
67
95
|
return "\n".join(lines) + "\n"
|
|
68
96
|
|
|
69
97
|
|
|
98
|
+
def author_bin_env(backend: str) -> str:
|
|
99
|
+
"""The `.env` key naming `backend`'s harness binary (`OUTERLOOP_CLAUDE_BIN`,
|
|
100
|
+
`OUTERLOOP_CODEX_BIN`), the same names `attempt` reads."""
|
|
101
|
+
return f"OUTERLOOP_{(backend or AUTHOR_BACKENDS[0]).upper()}_BIN"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
HARNESS_INSTALL = {
|
|
105
|
+
"claude": "npm install -g @anthropic-ai/claude-code "
|
|
106
|
+
"(or: curl -fsSL https://claude.ai/install.sh | bash)",
|
|
107
|
+
"codex": "npm install -g @openai/codex",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def locate_harness(backend: str) -> str:
|
|
112
|
+
"""The absolute path of `backend`'s CLI on this machine: PATH first, then
|
|
113
|
+
~/.local/bin (where the native installers put it and where a Slurm job,
|
|
114
|
+
with no login PATH, would not find it); "" when absent. Recorded in .env so
|
|
115
|
+
every job spawns the same binary the operator installed."""
|
|
116
|
+
name = backend or AUTHOR_BACKENDS[0]
|
|
117
|
+
found = shutil.which(name)
|
|
118
|
+
if found:
|
|
119
|
+
return str(Path(found).resolve())
|
|
120
|
+
local = Path.home() / ".local" / "bin" / name
|
|
121
|
+
return str(local) if local.is_file() and os.access(local, os.X_OK) else ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _harness_hint(answers: InitAnswers) -> None:
|
|
125
|
+
if not answers.author_bin:
|
|
126
|
+
name = answers.author_backend or AUTHOR_BACKENDS[0]
|
|
127
|
+
print(
|
|
128
|
+
f" no `{name}` binary found on PATH or in ~/.local/bin — the first climb would end\n"
|
|
129
|
+
f" with spawn-error. Install it: {HARNESS_INSTALL.get(name, 'see its docs')}\n"
|
|
130
|
+
" then run `outerloop init --force` so its path is recorded."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _token_login(token: str) -> str:
|
|
135
|
+
"""The login a PAT acts as (GET /user), or "" when GitHub does not say."""
|
|
136
|
+
req = urllib.request.Request(
|
|
137
|
+
f"{API}/user",
|
|
138
|
+
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github+json"},
|
|
139
|
+
)
|
|
140
|
+
try:
|
|
141
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
142
|
+
return str(json.loads(resp.read()).get("login") or "")
|
|
143
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
144
|
+
return ""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _auth_is_fatal(problem: str) -> bool:
|
|
148
|
+
"""A credential the loop cannot open pull requests with fails setup; not
|
|
149
|
+
being able to ask GitHub right now (network, 5xx, rate limit) does not."""
|
|
150
|
+
return bool(problem) and not problem.startswith(
|
|
151
|
+
("could not reach GitHub", "could not read the App", "could not check the App")
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def author_key_env(backend: str) -> str:
|
|
156
|
+
"""The `.env` key naming `backend`'s model-key file: one rule for every
|
|
157
|
+
backend (`OUTERLOOP_CLAUDE_KEY_FILE`, `OUTERLOOP_CODEX_KEY_FILE`)."""
|
|
158
|
+
return f"OUTERLOOP_{(backend or AUTHOR_BACKENDS[0]).upper()}_KEY_FILE"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def author_key_path(backend: str, *, config_dir: Path = CONFIG_DIR) -> Path:
|
|
162
|
+
"""Where `backend`'s model key lives by default: `<config dir>/<backend>_key`,
|
|
163
|
+
the same rule `attempt.resolve_author_key_file` reads."""
|
|
164
|
+
return config_dir / f"{backend or AUTHOR_BACKENDS[0]}_key"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def write_author_key(backend: str, key: str, *, config_dir: Path = CONFIG_DIR) -> Path:
|
|
168
|
+
"""Write a pasted model key to its default file, owner-only (0600), no
|
|
169
|
+
trailing newline. Returns the path."""
|
|
170
|
+
path = author_key_path(backend, config_dir=config_dir)
|
|
171
|
+
write_private(path, key)
|
|
172
|
+
return path
|
|
173
|
+
|
|
174
|
+
|
|
70
175
|
def write_config(
|
|
71
176
|
a: InitAnswers, token: str, pat_file: str, *, config_dir: Path = CONFIG_DIR
|
|
72
177
|
) -> tuple[Path, Path | None]:
|
|
@@ -79,13 +184,11 @@ def write_config(
|
|
|
79
184
|
pat_path = config_dir / DEFAULT_PAT_FILE.name
|
|
80
185
|
# printf-style: no trailing newline — the deploy `cat`s this file into
|
|
81
186
|
# the git credential, and a trailing newline rides along and breaks auth.
|
|
82
|
-
pat_path
|
|
83
|
-
pat_path.chmod(0o600)
|
|
187
|
+
write_private(pat_path, token)
|
|
84
188
|
written_pat = pat_path
|
|
85
189
|
pat_file = str(pat_path)
|
|
86
190
|
env_path = config_dir / ENV_FILE.name
|
|
87
|
-
env_path
|
|
88
|
-
env_path.chmod(0o600)
|
|
191
|
+
write_private(env_path, render_env(a, pat_file))
|
|
89
192
|
return env_path, written_pat
|
|
90
193
|
|
|
91
194
|
|
|
@@ -106,11 +209,69 @@ def _check_repo_access(token: str, target: str) -> str:
|
|
|
106
209
|
except urllib.error.HTTPError as exc:
|
|
107
210
|
if exc.code == 404:
|
|
108
211
|
return f"{target} not found, or the token cannot see it"
|
|
212
|
+
if exc.code == 401:
|
|
213
|
+
return "the token is invalid or expired (GitHub returned 401)"
|
|
214
|
+
if exc.code == 403:
|
|
215
|
+
return f"the token is not allowed to access {target} (GitHub returned 403)"
|
|
216
|
+
if exc.code == 429 or exc.code >= 500:
|
|
217
|
+
# GitHub's problem, not the credential's: never fatal
|
|
218
|
+
return f"could not reach GitHub: it returned {exc.code} for {target}"
|
|
109
219
|
return f"GitHub returned {exc.code} for {target}"
|
|
110
220
|
except urllib.error.URLError as exc:
|
|
111
221
|
return f"could not reach GitHub: {exc.reason}"
|
|
112
222
|
|
|
113
223
|
|
|
224
|
+
def _check_app_access(provider: Any, target: str) -> str:
|
|
225
|
+
"""Can this App write the target repo? "" on success, else a short reason.
|
|
226
|
+
|
|
227
|
+
An installation token does not populate a repository's `permissions`
|
|
228
|
+
object, so the PAT check above reads all-false for an App that can push.
|
|
229
|
+
The App is asked the two questions that decide it: is the target among
|
|
230
|
+
the repositories its installation covers, and does the installation carry
|
|
231
|
+
write on contents and pull requests. Never raises."""
|
|
232
|
+
from outerloop.appauth import build_app_jwt
|
|
233
|
+
|
|
234
|
+
headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
|
|
235
|
+
try:
|
|
236
|
+
# Asked as the App itself: GitHub answers with the installation that
|
|
237
|
+
# covers this repository, permissions included, or 404. A public
|
|
238
|
+
# repository would answer a plain read for any token, and the
|
|
239
|
+
# installation's repository list paginates, so neither is asked.
|
|
240
|
+
jwt = build_app_jwt(provider.app_id, time.time(), provider._sign)
|
|
241
|
+
req = urllib.request.Request(
|
|
242
|
+
f"{API}/repos/{target}/installation",
|
|
243
|
+
headers={**headers, "Authorization": f"Bearer {jwt}"},
|
|
244
|
+
)
|
|
245
|
+
try:
|
|
246
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
247
|
+
installation = json.loads(resp.read())
|
|
248
|
+
except urllib.error.HTTPError as exc:
|
|
249
|
+
if exc.code == 404:
|
|
250
|
+
return f"the App is not installed on {target}; install it there"
|
|
251
|
+
raise
|
|
252
|
+
if int(installation.get("id", 0)) != int(provider.installation_id):
|
|
253
|
+
return (
|
|
254
|
+
f"the App is installed on {target} under installation "
|
|
255
|
+
f"{installation.get('id')}, but the App file records {provider.installation_id}"
|
|
256
|
+
)
|
|
257
|
+
perms = installation.get("permissions") or {}
|
|
258
|
+
missing = [k for k in ("contents", "issues", "pull_requests") if perms.get(k) != "write"]
|
|
259
|
+
if missing:
|
|
260
|
+
return (
|
|
261
|
+
f"the App lacks write on {', '.join(missing)} "
|
|
262
|
+
"(it pushes branches, opens PRs, and files and closes issues)"
|
|
263
|
+
)
|
|
264
|
+
return ""
|
|
265
|
+
except urllib.error.HTTPError as exc:
|
|
266
|
+
if exc.code == 429 or exc.code >= 500:
|
|
267
|
+
return f"could not reach GitHub: it returned {exc.code} while checking the App"
|
|
268
|
+
return f"GitHub returned {exc.code} while checking the App"
|
|
269
|
+
except urllib.error.URLError as exc:
|
|
270
|
+
return f"could not reach GitHub: {exc.reason}"
|
|
271
|
+
except Exception as exc: # a check failure is a warning, never fails setup
|
|
272
|
+
return f"could not check the App: {exc}"
|
|
273
|
+
|
|
274
|
+
|
|
114
275
|
def validate_pat(pat_file: str, target: str) -> str:
|
|
115
276
|
"""Best-effort: can the PAT in `pat_file` write the target repo?"""
|
|
116
277
|
try:
|
|
@@ -145,7 +306,9 @@ def _collect(args: argparse.Namespace, interactive: bool) -> tuple[InitAnswers,
|
|
|
145
306
|
root = args.root or (
|
|
146
307
|
_ask("Slurm state root (shared filesystem)", required=True) if interactive else ""
|
|
147
308
|
)
|
|
148
|
-
account = args.account or (
|
|
309
|
+
account = args.account or (
|
|
310
|
+
_ask("Slurm account (blank = your default association)") if interactive else ""
|
|
311
|
+
)
|
|
149
312
|
partition = args.partition or (
|
|
150
313
|
_ask("Slurm partition (blank = Slurm default; a,b for a list)") if interactive else ""
|
|
151
314
|
)
|
|
@@ -158,6 +321,10 @@ def _collect(args: argparse.Namespace, interactive: bool) -> tuple[InitAnswers,
|
|
|
158
321
|
model = args.author_model or (
|
|
159
322
|
_ask("Author model (blank = the backend's default)") if ask_author else ""
|
|
160
323
|
)
|
|
324
|
+
# An explicit --image is recorded here (absolute: jobs read it from their
|
|
325
|
+
# own directory); the published one is fetched in main, after every check
|
|
326
|
+
# that could still end the run.
|
|
327
|
+
image = str(Path(args.image).expanduser().absolute()) if args.image else ""
|
|
161
328
|
answers = InitAnswers(
|
|
162
329
|
compute=compute,
|
|
163
330
|
target=target,
|
|
@@ -166,16 +333,99 @@ def _collect(args: argparse.Namespace, interactive: bool) -> tuple[InitAnswers,
|
|
|
166
333
|
partition=partition,
|
|
167
334
|
author_backend=backend,
|
|
168
335
|
author_model=model,
|
|
336
|
+
image=image,
|
|
337
|
+
author_bin=locate_harness(backend),
|
|
338
|
+
uncontained=bool(args.no_image) and not image,
|
|
169
339
|
)
|
|
170
340
|
return answers, (args.pat_file or "")
|
|
171
341
|
|
|
172
342
|
|
|
173
|
-
|
|
343
|
+
ORG_PROMPT = (
|
|
344
|
+
"Create the App under which account? (the repository's owner is the only one "
|
|
345
|
+
"the App can then be installed on; Enter to accept)"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _owner_type(owner: str) -> str:
|
|
350
|
+
""" "User" or "Organization" for a GitHub account, or "" when the lookup
|
|
351
|
+
fails. GitHub creates Apps at different pages for the two, and a wrong
|
|
352
|
+
guess sends the adopter to a page that cannot create the App."""
|
|
353
|
+
req = urllib.request.Request(
|
|
354
|
+
f"{API}/users/{owner}", headers={"Accept": "application/vnd.github+json"}
|
|
355
|
+
)
|
|
356
|
+
try:
|
|
357
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
358
|
+
return str(json.loads(resp.read()).get("type", ""))
|
|
359
|
+
except Exception:
|
|
360
|
+
return ""
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _app_failure(answers: InitAnswers, slug: str, problem: str) -> int:
|
|
364
|
+
"""The App cannot write the target: say so, keep the credentials, and point
|
|
365
|
+
at the fix and the re-check. Never `start` — the check just failed."""
|
|
366
|
+
print(f" auth check: FAILED — {problem}", file=sys.stderr)
|
|
367
|
+
print(
|
|
368
|
+
f"outerloop init: the App '{slug}' cannot write {answers.target}. On\n"
|
|
369
|
+
f" https://github.com/apps/{slug}/installations/new (or the App's page under\n"
|
|
370
|
+
" Settings > Installations > Configure) install it on that repository and accept\n"
|
|
371
|
+
" contents, issues and pull-request write access, then run\n"
|
|
372
|
+
" `outerloop init --force --github-app` to re-check with these credentials.",
|
|
373
|
+
file=sys.stderr,
|
|
374
|
+
)
|
|
375
|
+
return 1
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _github_app_recheck(answers: InitAnswers, app_json: Path) -> int:
|
|
379
|
+
"""Re-run Step 3 for an App this machine already created: confirm it can
|
|
380
|
+
write the target, then point the .env at it."""
|
|
381
|
+
from outerloop import appmanifest
|
|
382
|
+
from outerloop.appauth import app_provider_from_file
|
|
383
|
+
|
|
384
|
+
slug = app_json.name[len("github_app.") : -len(".json")]
|
|
385
|
+
print(f"Re-checking the existing App '{slug}' ({app_json}) against {answers.target}.")
|
|
386
|
+
try:
|
|
387
|
+
data = json.loads(app_json.read_text())
|
|
388
|
+
if not int(data.get("installation_id") or 0):
|
|
389
|
+
# the earlier run ended before the App was installed: look again
|
|
390
|
+
iid = appmanifest.capture_installation_id(
|
|
391
|
+
int(data["app_id"]), Path(str(data["private_key"])), answers.target.split("/")[0]
|
|
392
|
+
)
|
|
393
|
+
if iid:
|
|
394
|
+
appmanifest.set_installation_id(app_json, iid)
|
|
395
|
+
print(f" installation id {iid} recorded")
|
|
396
|
+
problem = _check_app_access(app_provider_from_file(app_json), answers.target)
|
|
397
|
+
except Exception as exc:
|
|
398
|
+
problem = f"could not read the App credentials: {exc}"
|
|
399
|
+
env_path = CONFIG_DIR / ENV_FILE.name
|
|
400
|
+
write_private(env_path, render_env(answers, app_file=str(app_json), bot_login=f"{slug}[bot]"))
|
|
401
|
+
if _auth_is_fatal(problem):
|
|
402
|
+
return _app_failure(answers, slug, problem)
|
|
403
|
+
print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
|
|
404
|
+
print(f"wrote {env_path}")
|
|
405
|
+
_author_key_hint(answers)
|
|
406
|
+
_harness_hint(answers)
|
|
407
|
+
print("next: outerloop start")
|
|
408
|
+
return 0
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _github_app_setup(
|
|
412
|
+
answers: InitAnswers, app_name: str, org: str, *, interactive: bool = True
|
|
413
|
+
) -> int:
|
|
174
414
|
"""The `--github-app` path: create the adopter's own App via the manifest
|
|
175
415
|
flow, write its creds, help install it, then point the .env at the App file.
|
|
176
416
|
Interactive by nature (a browser click + install), so no `--yes` variant."""
|
|
177
417
|
from outerloop import appmanifest
|
|
178
418
|
|
|
419
|
+
existing = sorted(CONFIG_DIR.glob("github_app.*.json"))
|
|
420
|
+
if len(existing) == 1:
|
|
421
|
+
# a re-run after fixing the installation: re-check the App this machine
|
|
422
|
+
# already has rather than creating a second one
|
|
423
|
+
reuse = True
|
|
424
|
+
if interactive:
|
|
425
|
+
answer = _ask(f"Reuse the App credentials at {existing[0]}? (Y/n)", "y")
|
|
426
|
+
reuse = not answer.lower().startswith("n")
|
|
427
|
+
if reuse:
|
|
428
|
+
return _github_app_recheck(answers, existing[0])
|
|
179
429
|
owner = org or answers.target.split("/")[0]
|
|
180
430
|
name = app_name or f"outerloop-{owner}"[:34] # GitHub caps App names at 34
|
|
181
431
|
code = appmanifest.request_manifest_code(
|
|
@@ -190,10 +440,26 @@ def _github_app_setup(answers: InitAnswers, app_name: str, org: str) -> int:
|
|
|
190
440
|
print(f"outerloop init: {exc}", file=sys.stderr)
|
|
191
441
|
return 1
|
|
192
442
|
pem_path, app_json = appmanifest.save_app_creds(conversion, CONFIG_DIR)
|
|
193
|
-
|
|
194
|
-
print(f"
|
|
195
|
-
|
|
443
|
+
repo = answers.target.split("/", 1)[-1]
|
|
444
|
+
print(f" created App '{conversion['slug']}'; credentials in {app_json} and {pem_path} (0600)")
|
|
445
|
+
print()
|
|
446
|
+
print(f"Step 2 of 3: install the App on {answers.target}.")
|
|
447
|
+
print(f" Open {appmanifest.install_url(conversion)}")
|
|
448
|
+
print(f" choose 'Only select repositories', pick '{repo}', and click 'Install'.")
|
|
449
|
+
input("Press Enter here once GitHub shows the App as installed… ")
|
|
450
|
+
print()
|
|
451
|
+
print(f"Step 3 of 3: checking that the App can write {answers.target}.")
|
|
196
452
|
iid = appmanifest.capture_installation_id(int(conversion["id"]), pem_path, owner)
|
|
453
|
+
for _ in range(2):
|
|
454
|
+
if iid:
|
|
455
|
+
break
|
|
456
|
+
# not installed yet: a rerun of init would stop at the existing config,
|
|
457
|
+
# so ask again here rather than sending the adopter around the loop
|
|
458
|
+
print(
|
|
459
|
+
f" no installation found yet. Install the App at {appmanifest.install_url(conversion)}"
|
|
460
|
+
)
|
|
461
|
+
input("Press Enter once GitHub shows the App as installed… ")
|
|
462
|
+
iid = appmanifest.capture_installation_id(int(conversion["id"]), pem_path, owner)
|
|
197
463
|
if iid:
|
|
198
464
|
appmanifest.set_installation_id(app_json, iid)
|
|
199
465
|
print(f" installation id {iid} recorded")
|
|
@@ -202,31 +468,49 @@ def _github_app_setup(answers: InitAnswers, app_name: str, org: str) -> int:
|
|
|
202
468
|
from outerloop.appauth import app_provider_from_file
|
|
203
469
|
|
|
204
470
|
try:
|
|
205
|
-
|
|
206
|
-
problem = _check_repo_access(token, answers.target)
|
|
207
|
-
print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
|
|
471
|
+
problem = _check_app_access(app_provider_from_file(app_json), answers.target)
|
|
208
472
|
except Exception as exc: # a check failure is a warning, never fails setup
|
|
209
|
-
|
|
473
|
+
problem = f"could not read the App credentials: {exc}"
|
|
474
|
+
if _auth_is_fatal(problem):
|
|
475
|
+
write_private(
|
|
476
|
+
CONFIG_DIR / ENV_FILE.name,
|
|
477
|
+
render_env(answers, app_file=str(app_json), bot_login=f"{conversion['slug']}[bot]"),
|
|
478
|
+
)
|
|
479
|
+
return _app_failure(answers, str(conversion["slug"]), problem)
|
|
480
|
+
print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
|
|
210
481
|
else:
|
|
211
|
-
|
|
212
|
-
|
|
482
|
+
# the credentials are kept; nothing can run until the App is installed
|
|
483
|
+
write_private(
|
|
484
|
+
CONFIG_DIR / ENV_FILE.name,
|
|
485
|
+
render_env(answers, app_file=str(app_json), bot_login=f"{conversion['slug']}[bot]"),
|
|
486
|
+
)
|
|
487
|
+
return _app_failure(
|
|
488
|
+
answers, str(conversion["slug"]), f"the App is not installed on {answers.target}"
|
|
213
489
|
)
|
|
214
490
|
env_path = CONFIG_DIR / ENV_FILE.name
|
|
215
|
-
|
|
216
|
-
|
|
491
|
+
write_private(
|
|
492
|
+
env_path,
|
|
493
|
+
render_env(answers, app_file=str(app_json), bot_login=f"{conversion['slug']}[bot]"),
|
|
494
|
+
)
|
|
217
495
|
print(f"wrote {env_path}")
|
|
496
|
+
_author_key_hint(answers)
|
|
497
|
+
_harness_hint(answers)
|
|
218
498
|
print("next: outerloop start")
|
|
219
499
|
return 0
|
|
220
500
|
|
|
221
501
|
|
|
222
502
|
def main(argv: list[str] | None = None) -> int:
|
|
223
503
|
parser = argparse.ArgumentParser(
|
|
224
|
-
prog="outerloop init",
|
|
504
|
+
prog="outerloop init",
|
|
505
|
+
description="Guided setup: asks for anything not given as a flag, checks the "
|
|
506
|
+
"credentials, and writes ~/.config/outerloop/.env.",
|
|
225
507
|
)
|
|
226
508
|
parser.add_argument("--compute", choices=["slurm", "local"], help="where the loop runs")
|
|
227
509
|
parser.add_argument("--target", help="the repo the agents work on, owner/repo")
|
|
228
510
|
parser.add_argument("--root", help="Slurm state root on the shared filesystem")
|
|
229
|
-
parser.add_argument(
|
|
511
|
+
parser.add_argument(
|
|
512
|
+
"--account", help="Slurm account (optional; unset bills your default association)"
|
|
513
|
+
)
|
|
230
514
|
parser.add_argument(
|
|
231
515
|
"--partition", help="Slurm partition (optional; blank = default; a,b = list)"
|
|
232
516
|
)
|
|
@@ -241,8 +525,29 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
241
525
|
)
|
|
242
526
|
parser.add_argument("--app-name", dest="app_name", help="name for the created GitHub App")
|
|
243
527
|
parser.add_argument("--org", help="create the App under this org (default: your account)")
|
|
244
|
-
parser.add_argument(
|
|
528
|
+
parser.add_argument(
|
|
529
|
+
"--author-backend",
|
|
530
|
+
dest="author_backend",
|
|
531
|
+
help=f"climbing author's backend ({' or '.join(AUTHOR_BACKENDS)}; default claude)",
|
|
532
|
+
)
|
|
245
533
|
parser.add_argument("--author-model", dest="author_model", help="climbing author's model")
|
|
534
|
+
image_flags = parser.add_mutually_exclusive_group() # one or the other, never both
|
|
535
|
+
image_flags.add_argument(
|
|
536
|
+
"--image",
|
|
537
|
+
help="path to an Apptainer image to run sessions and evals in (default: the "
|
|
538
|
+
"published one, downloaded on Linux when apptainer is installed)",
|
|
539
|
+
)
|
|
540
|
+
image_flags.add_argument(
|
|
541
|
+
"--no-image",
|
|
542
|
+
dest="no_image",
|
|
543
|
+
action="store_true",
|
|
544
|
+
help="no image at all: runs stay uncontained even if one is already on disk",
|
|
545
|
+
)
|
|
546
|
+
parser.add_argument(
|
|
547
|
+
"--author-key-file",
|
|
548
|
+
dest="author_key_file",
|
|
549
|
+
help="path to an existing file holding the author's model API key",
|
|
550
|
+
)
|
|
246
551
|
parser.add_argument(
|
|
247
552
|
"--yes", "-y", action="store_true", help="non-interactive: use flags, do not prompt"
|
|
248
553
|
)
|
|
@@ -256,8 +561,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
256
561
|
if not answers.target:
|
|
257
562
|
print("outerloop init: a target repo is required (--target owner/repo)", file=sys.stderr)
|
|
258
563
|
return 2
|
|
259
|
-
if answers.compute == "slurm" and not
|
|
260
|
-
|
|
564
|
+
if answers.compute == "slurm" and not answers.root:
|
|
565
|
+
# the account and the partition are optional: Slurm's defaults apply
|
|
566
|
+
print("outerloop init: Slurm needs --root", file=sys.stderr)
|
|
261
567
|
return 2
|
|
262
568
|
if answers.author_backend and answers.author_backend not in AUTHOR_BACKENDS:
|
|
263
569
|
print(
|
|
@@ -265,6 +571,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
265
571
|
file=sys.stderr,
|
|
266
572
|
)
|
|
267
573
|
return 2
|
|
574
|
+
if answers.image and not Path(answers.image).is_file():
|
|
575
|
+
# a typo here would surface as an uncontained loop later
|
|
576
|
+
print(f"outerloop init: --image {answers.image} is not a file", file=sys.stderr)
|
|
577
|
+
return 2
|
|
268
578
|
|
|
269
579
|
# Never clobber a working setup silently: a re-run of init on a configured
|
|
270
580
|
# machine must ask (or be told --force). Checked before any App is created.
|
|
@@ -280,6 +590,39 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
280
590
|
)
|
|
281
591
|
return 1
|
|
282
592
|
|
|
593
|
+
# The author's model key: an existing file by flag, or (on the full
|
|
594
|
+
# interactive run, never the focused --github-app one) a hidden paste written
|
|
595
|
+
# to <config>/<backend>_key. Collected after the overwrite check so a
|
|
596
|
+
# declined run never asks for a secret.
|
|
597
|
+
if args.author_key_file:
|
|
598
|
+
key_file = Path(args.author_key_file).expanduser()
|
|
599
|
+
if not key_file.is_file() or not os.access(key_file, os.R_OK):
|
|
600
|
+
print(
|
|
601
|
+
f"outerloop init: --author-key-file {key_file} is not a readable file",
|
|
602
|
+
file=sys.stderr,
|
|
603
|
+
)
|
|
604
|
+
return 2
|
|
605
|
+
# absolute: climb jobs read the key from their own flight directory
|
|
606
|
+
answers.author_key_file = str(key_file.absolute())
|
|
607
|
+
if not answers.author_key_file and interactive and not args.github_app:
|
|
608
|
+
pasted = getpass.getpass(
|
|
609
|
+
f"API key for the {answers.author_backend or AUTHOR_BACKENDS[0]} author "
|
|
610
|
+
"(hidden; blank to set later): "
|
|
611
|
+
).strip()
|
|
612
|
+
if pasted:
|
|
613
|
+
key_path = write_author_key(answers.author_backend, pasted, config_dir=CONFIG_DIR)
|
|
614
|
+
answers.author_key_file = str(key_path)
|
|
615
|
+
print(f"wrote {key_path} (0600)")
|
|
616
|
+
|
|
617
|
+
# The image, only now: every check that could still end the run has passed
|
|
618
|
+
# and the overwrite question is answered, so a download is never wasted.
|
|
619
|
+
# An existing image is used, else the published one is fetched on a machine
|
|
620
|
+
# that can run it (asked first when interactive); --no-image opts out.
|
|
621
|
+
if not answers.image and not args.no_image:
|
|
622
|
+
# the container probe is for local mode: on Slurm the image runs on
|
|
623
|
+
# compute nodes, which the login node cannot speak for
|
|
624
|
+
answers.image = ensure_image(interactive=interactive, probe=(answers.compute == "local"))
|
|
625
|
+
|
|
283
626
|
# The App is the recommended credential (scoped, revocable, no plaintext
|
|
284
627
|
# token); the PAT is the fallback. Offer it first when interactive.
|
|
285
628
|
if not args.github_app and not pat_file and interactive:
|
|
@@ -290,7 +633,20 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
290
633
|
if choice.startswith("a"):
|
|
291
634
|
args.github_app = True
|
|
292
635
|
if args.github_app:
|
|
293
|
-
|
|
636
|
+
org = args.org or ""
|
|
637
|
+
if not org and interactive:
|
|
638
|
+
# a private App installs only on the account that created it, so
|
|
639
|
+
# the target's owner is the only default that can work
|
|
640
|
+
owner = answers.target.split("/")[0]
|
|
641
|
+
org = _ask(ORG_PROMPT, owner).strip()
|
|
642
|
+
if org:
|
|
643
|
+
kind = _owner_type(org)
|
|
644
|
+
if not kind and interactive:
|
|
645
|
+
answer = _ask(f"Is '{org}' an organization or a user account? [org/user]", "org")
|
|
646
|
+
kind = "User" if answer.strip().lower().startswith("u") else "Organization"
|
|
647
|
+
if kind == "User":
|
|
648
|
+
org = "" # a user account: GitHub's personal App page, not an org page
|
|
649
|
+
return _github_app_setup(answers, args.app_name or "", org, interactive=interactive)
|
|
294
650
|
|
|
295
651
|
token = ""
|
|
296
652
|
if not pat_file and interactive:
|
|
@@ -306,8 +662,40 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
306
662
|
effective_pat = str(pat_path) if pat_path else pat_file
|
|
307
663
|
if effective_pat:
|
|
308
664
|
problem = validate_pat(effective_pat, answers.target)
|
|
665
|
+
if _auth_is_fatal(problem):
|
|
666
|
+
# the loop opens pull requests with this token; sending the adopter
|
|
667
|
+
# to `start` would fail later and further from the cause
|
|
668
|
+
print(f" auth check: FAILED — {problem}", file=sys.stderr)
|
|
669
|
+
print(
|
|
670
|
+
f"outerloop init: fix the token in {effective_pat} (write access to "
|
|
671
|
+
f"{answers.target}), then run `outerloop init --force`",
|
|
672
|
+
file=sys.stderr,
|
|
673
|
+
)
|
|
674
|
+
return 1
|
|
309
675
|
print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
|
|
676
|
+
if not problem:
|
|
677
|
+
# record the login the kernel will post as: every own-comment and
|
|
678
|
+
# own-PR filter keys on it, and a default that is not this token's
|
|
679
|
+
# identity would hide the adopter's own PRs from the kernel
|
|
680
|
+
try:
|
|
681
|
+
login = _token_login(Path(effective_pat).expanduser().read_text().strip())
|
|
682
|
+
except OSError:
|
|
683
|
+
login = ""
|
|
684
|
+
if login:
|
|
685
|
+
write_private(env_path, render_env(answers, effective_pat, bot_login=login))
|
|
686
|
+
print(f" posting as {login} (OUTERLOOP_BOT_LOGIN)")
|
|
310
687
|
else:
|
|
311
688
|
print(" no PAT set — add OUTERLOOP_PAT_FILE before the agents can open PRs")
|
|
689
|
+
_author_key_hint(answers)
|
|
690
|
+
_harness_hint(answers)
|
|
312
691
|
print("next: outerloop start")
|
|
313
692
|
return 0
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _author_key_hint(answers: InitAnswers) -> None:
|
|
696
|
+
if not answers.author_key_file:
|
|
697
|
+
print(
|
|
698
|
+
" no author key set — put it in "
|
|
699
|
+
f"{author_key_path(answers.author_backend, config_dir=CONFIG_DIR)} "
|
|
700
|
+
"before the first climb"
|
|
701
|
+
)
|
outerloop/limits.py
CHANGED
|
@@ -26,7 +26,7 @@ from typing import Any
|
|
|
26
26
|
# work — session budgets sized for solver tweaks starve construction work.
|
|
27
27
|
# floor = session floor + overhead + self-deadline margin: even at the
|
|
28
28
|
# floors, a session must fit inside its job with the ending's runway.
|
|
29
|
-
# Public: the tick's
|
|
29
|
+
# Public: the tick's OUTERLOOP_MAX_JOB_MINUTES knob floors here too.
|
|
30
30
|
ATTEMPT_JOB_MINUTES_FLOOR = 40
|
|
31
31
|
|
|
32
32
|
# Public: the tick shrinks a capped job's session with the same floor the
|
outerloop/measure.py
CHANGED
|
@@ -278,7 +278,7 @@ class DispatchedMeasurer:
|
|
|
278
278
|
if not self.gpu_partition:
|
|
279
279
|
raise ValueError(
|
|
280
280
|
f"measure {m.name} needs {m.gpus} GPU(s) but no GPU lane is configured "
|
|
281
|
-
"(set
|
|
281
|
+
"(set OUTERLOOP_GPU_PARTITION)"
|
|
282
282
|
)
|
|
283
283
|
return self.gpu_account or self.account, self.gpu_partition
|
|
284
284
|
|
|
@@ -494,7 +494,7 @@ class DispatchSettings:
|
|
|
494
494
|
if not self.gpu_partition:
|
|
495
495
|
raise ValueError(
|
|
496
496
|
f"benchmark needs {gpus} GPU(s) but no GPU lane is configured "
|
|
497
|
-
"(set
|
|
497
|
+
"(set OUTERLOOP_GPU_PARTITION)"
|
|
498
498
|
)
|
|
499
499
|
return self.gpu_account or self.account, self.gpu_partition
|
|
500
500
|
|
outerloop/orchestrator.py
CHANGED
|
@@ -361,7 +361,7 @@ class SubprocessEvaluator:
|
|
|
361
361
|
)
|
|
362
362
|
except OSError as exc:
|
|
363
363
|
raise EvalError(f"could not create check home: {exc}") from exc
|
|
364
|
-
cache_dir = Path(tempfile.mkdtemp(prefix="
|
|
364
|
+
cache_dir = Path(tempfile.mkdtemp(prefix="outerloop-check-cache-"))
|
|
365
365
|
try:
|
|
366
366
|
self._run(workspace, command, eval_home, cache_dir)
|
|
367
367
|
finally:
|