outerloop-science 0.1.0.dev1__py3-none-any.whl → 0.1.0.dev2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- outerloop/__init__.py +7 -7
- outerloop/appauth.py +3 -3
- outerloop/appmanifest.py +1 -1
- outerloop/attempt.py +55 -49
- outerloop/cli.py +150 -74
- outerloop/compute.py +59 -9
- outerloop/contract.py +3 -2
- outerloop/followup.py +14 -17
- outerloop/github.py +4 -4
- outerloop/harness.py +18 -7
- outerloop/image.py +372 -0
- outerloop/init.py +229 -14
- outerloop/limits.py +1 -1
- outerloop/measure.py +2 -2
- outerloop/orchestrator.py +1 -1
- outerloop/paths.py +2 -1
- outerloop/review.py +1 -1
- outerloop/steward.py +3 -3
- outerloop/syscall.py +1 -0
- outerloop/tick.py +302 -77
- {outerloop_science-0.1.0.dev1.dist-info → outerloop_science-0.1.0.dev2.dist-info}/METADATA +2 -2
- {outerloop_science-0.1.0.dev1.dist-info → outerloop_science-0.1.0.dev2.dist-info}/RECORD +26 -25
- {outerloop_science-0.1.0.dev1.dist-info → outerloop_science-0.1.0.dev2.dist-info}/WHEEL +0 -0
- {outerloop_science-0.1.0.dev1.dist-info → outerloop_science-0.1.0.dev2.dist-info}/entry_points.txt +0 -0
- {outerloop_science-0.1.0.dev1.dist-info → outerloop_science-0.1.0.dev2.dist-info}/licenses/LICENSE +0 -0
- {outerloop_science-0.1.0.dev1.dist-info → outerloop_science-0.1.0.dev2.dist-info}/licenses/NOTICE +0 -0
outerloop/init.py
CHANGED
|
@@ -20,6 +20,7 @@ import argparse
|
|
|
20
20
|
import getpass
|
|
21
21
|
import json
|
|
22
22
|
import os
|
|
23
|
+
import shutil
|
|
23
24
|
import sys
|
|
24
25
|
import time
|
|
25
26
|
import urllib.error
|
|
@@ -29,6 +30,7 @@ from pathlib import Path
|
|
|
29
30
|
from typing import Any
|
|
30
31
|
|
|
31
32
|
from outerloop.cli import ENV_FILE
|
|
33
|
+
from outerloop.image import ensure_image
|
|
32
34
|
from outerloop.paths import write_private
|
|
33
35
|
|
|
34
36
|
CONFIG_DIR = ENV_FILE.parent
|
|
@@ -43,11 +45,14 @@ class InitAnswers:
|
|
|
43
45
|
compute: str # "slurm" | "local"
|
|
44
46
|
target: str # "owner/repo"
|
|
45
47
|
root: str = "" # Slurm state root on the shared filesystem
|
|
46
|
-
account: str = "" # Slurm account (
|
|
48
|
+
account: str = "" # Slurm account (optional; unset -> the default association)
|
|
47
49
|
partition: str = "" # Slurm partition (optional; unset -> Slurm default)
|
|
48
50
|
author_backend: str = "" # optional: the climbing author's harness
|
|
49
51
|
author_model: str = "" # optional
|
|
50
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
|
|
51
56
|
|
|
52
57
|
|
|
53
58
|
def render_env(
|
|
@@ -60,9 +65,16 @@ def render_env(
|
|
|
60
65
|
lines = [f"OUTERLOOP_COMPUTE={a.compute}"]
|
|
61
66
|
if a.compute == "slurm":
|
|
62
67
|
lines.append(f"OUTERLOOP_ROOT={a.root}")
|
|
63
|
-
|
|
68
|
+
if a.account: # optional: unset bills the caller's default association
|
|
69
|
+
lines.append(f"OUTERLOOP_ACCOUNT={a.account}")
|
|
64
70
|
if a.partition: # optional: unset lets Slurm pick its default partition
|
|
65
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=")
|
|
66
78
|
lines.append(f"OUTERLOOP_TARGET={a.target}")
|
|
67
79
|
if app_file:
|
|
68
80
|
lines.append(f"OUTERLOOP_GITHUB_APP_FILE={app_file}")
|
|
@@ -78,9 +90,68 @@ def render_env(
|
|
|
78
90
|
lines.append(f"OUTERLOOP_AUTHOR_MODEL={a.author_model}")
|
|
79
91
|
if a.author_key_file:
|
|
80
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}")
|
|
81
95
|
return "\n".join(lines) + "\n"
|
|
82
96
|
|
|
83
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
|
+
|
|
84
155
|
def author_key_env(backend: str) -> str:
|
|
85
156
|
"""The `.env` key naming `backend`'s model-key file: one rule for every
|
|
86
157
|
backend (`OUTERLOOP_CLAUDE_KEY_FILE`, `OUTERLOOP_CODEX_KEY_FILE`)."""
|
|
@@ -138,6 +209,13 @@ def _check_repo_access(token: str, target: str) -> str:
|
|
|
138
209
|
except urllib.error.HTTPError as exc:
|
|
139
210
|
if exc.code == 404:
|
|
140
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}"
|
|
141
219
|
return f"GitHub returned {exc.code} for {target}"
|
|
142
220
|
except urllib.error.URLError as exc:
|
|
143
221
|
return f"could not reach GitHub: {exc.reason}"
|
|
@@ -185,6 +263,8 @@ def _check_app_access(provider: Any, target: str) -> str:
|
|
|
185
263
|
)
|
|
186
264
|
return ""
|
|
187
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"
|
|
188
268
|
return f"GitHub returned {exc.code} while checking the App"
|
|
189
269
|
except urllib.error.URLError as exc:
|
|
190
270
|
return f"could not reach GitHub: {exc.reason}"
|
|
@@ -226,7 +306,9 @@ def _collect(args: argparse.Namespace, interactive: bool) -> tuple[InitAnswers,
|
|
|
226
306
|
root = args.root or (
|
|
227
307
|
_ask("Slurm state root (shared filesystem)", required=True) if interactive else ""
|
|
228
308
|
)
|
|
229
|
-
account = args.account or (
|
|
309
|
+
account = args.account or (
|
|
310
|
+
_ask("Slurm account (blank = your default association)") if interactive else ""
|
|
311
|
+
)
|
|
230
312
|
partition = args.partition or (
|
|
231
313
|
_ask("Slurm partition (blank = Slurm default; a,b for a list)") if interactive else ""
|
|
232
314
|
)
|
|
@@ -239,6 +321,10 @@ def _collect(args: argparse.Namespace, interactive: bool) -> tuple[InitAnswers,
|
|
|
239
321
|
model = args.author_model or (
|
|
240
322
|
_ask("Author model (blank = the backend's default)") if ask_author else ""
|
|
241
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 ""
|
|
242
328
|
answers = InitAnswers(
|
|
243
329
|
compute=compute,
|
|
244
330
|
target=target,
|
|
@@ -247,6 +333,9 @@ def _collect(args: argparse.Namespace, interactive: bool) -> tuple[InitAnswers,
|
|
|
247
333
|
partition=partition,
|
|
248
334
|
author_backend=backend,
|
|
249
335
|
author_model=model,
|
|
336
|
+
image=image,
|
|
337
|
+
author_bin=locate_harness(backend),
|
|
338
|
+
uncontained=bool(args.no_image) and not image,
|
|
250
339
|
)
|
|
251
340
|
return answers, (args.pat_file or "")
|
|
252
341
|
|
|
@@ -271,12 +360,72 @@ def _owner_type(owner: str) -> str:
|
|
|
271
360
|
return ""
|
|
272
361
|
|
|
273
362
|
|
|
274
|
-
def
|
|
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:
|
|
275
414
|
"""The `--github-app` path: create the adopter's own App via the manifest
|
|
276
415
|
flow, write its creds, help install it, then point the .env at the App file.
|
|
277
416
|
Interactive by nature (a browser click + install), so no `--yes` variant."""
|
|
278
417
|
from outerloop import appmanifest
|
|
279
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])
|
|
280
429
|
owner = org or answers.target.split("/")[0]
|
|
281
430
|
name = app_name or f"outerloop-{owner}"[:34] # GitHub caps App names at 34
|
|
282
431
|
code = appmanifest.request_manifest_code(
|
|
@@ -322,12 +471,21 @@ def _github_app_setup(answers: InitAnswers, app_name: str, org: str) -> int:
|
|
|
322
471
|
problem = _check_app_access(app_provider_from_file(app_json), answers.target)
|
|
323
472
|
except Exception as exc: # a check failure is a warning, never fails setup
|
|
324
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)
|
|
325
480
|
print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
|
|
326
481
|
else:
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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}"
|
|
331
489
|
)
|
|
332
490
|
env_path = CONFIG_DIR / ENV_FILE.name
|
|
333
491
|
write_private(
|
|
@@ -336,18 +494,23 @@ def _github_app_setup(answers: InitAnswers, app_name: str, org: str) -> int:
|
|
|
336
494
|
)
|
|
337
495
|
print(f"wrote {env_path}")
|
|
338
496
|
_author_key_hint(answers)
|
|
497
|
+
_harness_hint(answers)
|
|
339
498
|
print("next: outerloop start")
|
|
340
499
|
return 0
|
|
341
500
|
|
|
342
501
|
|
|
343
502
|
def main(argv: list[str] | None = None) -> int:
|
|
344
503
|
parser = argparse.ArgumentParser(
|
|
345
|
-
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.",
|
|
346
507
|
)
|
|
347
508
|
parser.add_argument("--compute", choices=["slurm", "local"], help="where the loop runs")
|
|
348
509
|
parser.add_argument("--target", help="the repo the agents work on, owner/repo")
|
|
349
510
|
parser.add_argument("--root", help="Slurm state root on the shared filesystem")
|
|
350
|
-
parser.add_argument(
|
|
511
|
+
parser.add_argument(
|
|
512
|
+
"--account", help="Slurm account (optional; unset bills your default association)"
|
|
513
|
+
)
|
|
351
514
|
parser.add_argument(
|
|
352
515
|
"--partition", help="Slurm partition (optional; blank = default; a,b = list)"
|
|
353
516
|
)
|
|
@@ -362,8 +525,24 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
362
525
|
)
|
|
363
526
|
parser.add_argument("--app-name", dest="app_name", help="name for the created GitHub App")
|
|
364
527
|
parser.add_argument("--org", help="create the App under this org (default: your account)")
|
|
365
|
-
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
|
+
)
|
|
366
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
|
+
)
|
|
367
546
|
parser.add_argument(
|
|
368
547
|
"--author-key-file",
|
|
369
548
|
dest="author_key_file",
|
|
@@ -382,8 +561,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
382
561
|
if not answers.target:
|
|
383
562
|
print("outerloop init: a target repo is required (--target owner/repo)", file=sys.stderr)
|
|
384
563
|
return 2
|
|
385
|
-
if answers.compute == "slurm" and not
|
|
386
|
-
|
|
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)
|
|
387
567
|
return 2
|
|
388
568
|
if answers.author_backend and answers.author_backend not in AUTHOR_BACKENDS:
|
|
389
569
|
print(
|
|
@@ -391,6 +571,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
391
571
|
file=sys.stderr,
|
|
392
572
|
)
|
|
393
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
|
|
394
578
|
|
|
395
579
|
# Never clobber a working setup silently: a re-run of init on a configured
|
|
396
580
|
# machine must ask (or be told --force). Checked before any App is created.
|
|
@@ -430,6 +614,15 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
430
614
|
answers.author_key_file = str(key_path)
|
|
431
615
|
print(f"wrote {key_path} (0600)")
|
|
432
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
|
+
|
|
433
626
|
# The App is the recommended credential (scoped, revocable, no plaintext
|
|
434
627
|
# token); the PAT is the fallback. Offer it first when interactive.
|
|
435
628
|
if not args.github_app and not pat_file and interactive:
|
|
@@ -453,7 +646,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
453
646
|
kind = "User" if answer.strip().lower().startswith("u") else "Organization"
|
|
454
647
|
if kind == "User":
|
|
455
648
|
org = "" # a user account: GitHub's personal App page, not an org page
|
|
456
|
-
return _github_app_setup(answers, args.app_name or "", org)
|
|
649
|
+
return _github_app_setup(answers, args.app_name or "", org, interactive=interactive)
|
|
457
650
|
|
|
458
651
|
token = ""
|
|
459
652
|
if not pat_file and interactive:
|
|
@@ -469,10 +662,32 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
469
662
|
effective_pat = str(pat_path) if pat_path else pat_file
|
|
470
663
|
if effective_pat:
|
|
471
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
|
|
472
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)")
|
|
473
687
|
else:
|
|
474
688
|
print(" no PAT set — add OUTERLOOP_PAT_FILE before the agents can open PRs")
|
|
475
689
|
_author_key_hint(answers)
|
|
690
|
+
_harness_hint(answers)
|
|
476
691
|
print("next: outerloop start")
|
|
477
692
|
return 0
|
|
478
693
|
|
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:
|
outerloop/paths.py
CHANGED
|
@@ -12,7 +12,8 @@ from __future__ import annotations
|
|
|
12
12
|
import os
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# New name first; the pre-rename name is dropped in the release after 0.1.
|
|
16
|
+
CONFIG_DIR_NAMES: tuple[str, ...] = ("outerloop", "autoresearch")
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
def config_dir(home: Path | None = None) -> Path:
|
outerloop/review.py
CHANGED
|
@@ -34,7 +34,7 @@ OPT_OUT_LABEL = label_name("no-review")
|
|
|
34
34
|
# One calm line: the mechanical defense against forged endorsements is the
|
|
35
35
|
# approval-language redaction in sanitize(), not header volume.
|
|
36
36
|
ADVISORY_HEADER = (
|
|
37
|
-
"*Advisory findings from `
|
|
37
|
+
"*Advisory findings from `outerloop` — the code owner decides. "
|
|
38
38
|
f"Reply to disagree; the `{OPT_OUT_LABEL}` label opts this PR out.*"
|
|
39
39
|
)
|
|
40
40
|
MAX_DIFF_CHARS = 200_000
|
outerloop/steward.py
CHANGED
|
@@ -43,7 +43,7 @@ from outerloop.github import (
|
|
|
43
43
|
bot_login_from_env,
|
|
44
44
|
is_own_login,
|
|
45
45
|
)
|
|
46
|
-
from outerloop.harness import Harness, budget_exhausted, outage, redact
|
|
46
|
+
from outerloop.harness import Harness, budget_exhausted, default_binary, outage, redact
|
|
47
47
|
from outerloop.intake import (
|
|
48
48
|
CLAIM_MARKER,
|
|
49
49
|
RELEASE_MARKER,
|
|
@@ -768,7 +768,7 @@ def main() -> int:
|
|
|
768
768
|
action="store_true",
|
|
769
769
|
help="run WITHOUT a container (dev only)",
|
|
770
770
|
)
|
|
771
|
-
parser.add_argument("--claude-bin", default=
|
|
771
|
+
parser.add_argument("--claude-bin", default=default_binary("claude"))
|
|
772
772
|
parser.add_argument("--model", default="claude-opus-5")
|
|
773
773
|
parser.add_argument("--max-turns", type=int, default=60)
|
|
774
774
|
parser.add_argument("--session-minutes", type=int, default=60)
|
|
@@ -777,7 +777,7 @@ def main() -> int:
|
|
|
777
777
|
parser.add_argument("--pat-file", default=str(CONFIG_DIR / "bot_pat"))
|
|
778
778
|
parser.add_argument(
|
|
779
779
|
"--github-app-file",
|
|
780
|
-
default=os.environ.get("
|
|
780
|
+
default=os.environ.get("OUTERLOOP_GITHUB_APP_FILE", ""),
|
|
781
781
|
help="GitHub App config (JSON: app_id, installation_id, private_key); "
|
|
782
782
|
"when set, installation tokens replace the PAT",
|
|
783
783
|
)
|
outerloop/syscall.py
CHANGED
|
@@ -50,6 +50,7 @@ from outerloop.compute import GONE
|
|
|
50
50
|
# workspace and the session's own memory of the path both predate the rename.
|
|
51
51
|
# `channel_dir(ws)` resolves per workspace: existing dir (new name first), else
|
|
52
52
|
# the new default. Every site keys off it, so a resumed run finds its own path.
|
|
53
|
+
# The pre-rename name is dropped in the release after 0.1.
|
|
53
54
|
CHANNEL_DIR_NAMES: tuple[str, ...] = (".outerloop", ".autoresearch")
|
|
54
55
|
SYSCALL_DIR = CHANNEL_DIR_NAMES[0] # the new default (a fresh clone installs this)
|
|
55
56
|
SYSCALL_FILE = "syscall.json"
|