outerloop-science 0.1.0.dev0__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 +18 -0
- outerloop/__main__.py +3 -0
- outerloop/appauth.py +213 -0
- outerloop/appmanifest.py +198 -0
- outerloop/attempt.py +3481 -0
- outerloop/brief.py +515 -0
- outerloop/cli.py +439 -0
- outerloop/climbboard.py +1145 -0
- outerloop/compute.py +482 -0
- outerloop/contract.py +483 -0
- outerloop/contract_cli.py +63 -0
- outerloop/disk.py +164 -0
- outerloop/dispatch.py +586 -0
- outerloop/followup.py +2143 -0
- outerloop/github.py +1486 -0
- outerloop/harness.py +1449 -0
- outerloop/housekeeping.py +167 -0
- outerloop/init.py +313 -0
- outerloop/intake.py +129 -0
- outerloop/limits.py +80 -0
- outerloop/markers.py +48 -0
- outerloop/measure.py +523 -0
- outerloop/orchestrator.py +1901 -0
- outerloop/panel.py +188 -0
- outerloop/paths.py +27 -0
- outerloop/posting.py +160 -0
- outerloop/progress.py +170 -0
- outerloop/py.typed +0 -0
- outerloop/review.py +611 -0
- outerloop/review_agent.py +263 -0
- outerloop/review_agent_cli.py +209 -0
- outerloop/review_post_cli.py +162 -0
- outerloop/review_summarize_cli.py +163 -0
- outerloop/role_runner.py +229 -0
- outerloop/roles.py +247 -0
- outerloop/rolespec.py +89 -0
- outerloop/runstate.py +385 -0
- outerloop/steward.py +852 -0
- outerloop/style.py +12 -0
- outerloop/syscall.py +977 -0
- outerloop/syscall_cli.py +531 -0
- outerloop/tick.py +3166 -0
- outerloop/verifier.py +403 -0
- outerloop/verify_agent.py +149 -0
- outerloop/verify_agent_cli.py +95 -0
- outerloop/verify_post_cli.py +116 -0
- outerloop_science-0.1.0.dev0.dist-info/METADATA +145 -0
- outerloop_science-0.1.0.dev0.dist-info/RECORD +52 -0
- outerloop_science-0.1.0.dev0.dist-info/WHEEL +4 -0
- outerloop_science-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- outerloop_science-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
- outerloop_science-0.1.0.dev0.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Disk housekeeping: ended runs shed their workspaces.
|
|
2
|
+
|
|
3
|
+
A run's workspace (`ws/`, the clone the session worked in, and `ws-home/`,
|
|
4
|
+
the session's home with its tool caches) is the bulk of what a run leaves
|
|
5
|
+
on the state filesystem, in files far more than in bytes: 2026-09-03 the
|
|
6
|
+
scratch quota on Torch hit its 5M-file ceiling with 1.67M of them under
|
|
7
|
+
state/runs, and no tick could start for two hours. Everything the record
|
|
8
|
+
keeps for the research record lives outside those two directories: the
|
|
9
|
+
run's state.json, its report, its transcripts, and the ledger entries; the
|
|
10
|
+
tree itself is on GitHub (the PR branch, or the research line's snapshot).
|
|
11
|
+
|
|
12
|
+
Rules (docs/design/disk-maintenance.md):
|
|
13
|
+
|
|
14
|
+
- only ENDED runs shed, and only the two directories `ws` and `ws-home`;
|
|
15
|
+
- after a grace period (default 24 h, for post-mortems), oldest first;
|
|
16
|
+
- when the state filesystem's write probe FAILS, the grace is waived: the
|
|
17
|
+
tick sheds until the probe passes again, so a full quota heals itself;
|
|
18
|
+
- a workspace whose top-level entry is a symlink is not removed (a session
|
|
19
|
+
could aim it anywhere); it is logged and left for a human;
|
|
20
|
+
- the record notes when it shed (`workspace_shed`), so nothing runs twice.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import shutil
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import replace
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from outerloop.runstate import ENDED, RunRecord, list_runs, load_record, run_dir, save_record
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
WORKSPACE_DIRS = ("ws", "ws-home")
|
|
38
|
+
DEFAULT_SHED_GRACE_S = 24 * 3600.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def shed_candidates(root: Path, now: float, grace_s: float, force: bool) -> list[RunRecord]:
|
|
42
|
+
"""Ended runs whose workspaces may go now, oldest ending first. With
|
|
43
|
+
`force` (the disk is failing) the grace period does not apply."""
|
|
44
|
+
due: list[RunRecord] = []
|
|
45
|
+
for record in list_runs(root):
|
|
46
|
+
if record.state != ENDED or record.workspace_shed:
|
|
47
|
+
continue
|
|
48
|
+
if not force and now - record.updated < grace_s:
|
|
49
|
+
continue
|
|
50
|
+
if not any((run_dir(root, record.run_id) / d).exists() for d in WORKSPACE_DIRS):
|
|
51
|
+
continue
|
|
52
|
+
due.append(record)
|
|
53
|
+
due.sort(key=lambda r: r.updated)
|
|
54
|
+
return due
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def shed_workspace(root: Path, record: RunRecord, now: float) -> bool:
|
|
58
|
+
"""Remove the run's `ws` and `ws-home` directories and stamp the record.
|
|
59
|
+
Returns False (and removes nothing) when either is a symlink."""
|
|
60
|
+
base = run_dir(root, record.run_id)
|
|
61
|
+
targets = [base / d for d in WORKSPACE_DIRS if (base / d).exists() or (base / d).is_symlink()]
|
|
62
|
+
for path in targets:
|
|
63
|
+
if path.is_symlink():
|
|
64
|
+
log.warning("not shedding %s: %s is a symlink", record.run_id, path.name)
|
|
65
|
+
return False
|
|
66
|
+
for path in targets:
|
|
67
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
68
|
+
remaining = [p.name for p in targets if p.exists()]
|
|
69
|
+
if remaining:
|
|
70
|
+
log.warning("shed %s incompletely: %s remain", record.run_id, ", ".join(remaining))
|
|
71
|
+
return False
|
|
72
|
+
save_record(root, replace(record, workspace_shed=now), now)
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_TS_RE = re.compile(r"(\d{8}-\d{6})")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _run_id_timestamp(run_id: str) -> str | None:
|
|
80
|
+
m = _TS_RE.search(run_id)
|
|
81
|
+
return m.group(1) if m else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_shed_candidate(
|
|
85
|
+
root: Path, record: RunRecord, now: float, grace_s: float, force: bool
|
|
86
|
+
) -> bool:
|
|
87
|
+
if record.state != ENDED or record.workspace_shed:
|
|
88
|
+
return False
|
|
89
|
+
if not force and now - record.updated < grace_s:
|
|
90
|
+
return False
|
|
91
|
+
return any((run_dir(root, record.run_id) / d).exists() for d in WORKSPACE_DIRS)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def shed_ended_workspaces(
|
|
95
|
+
root: Path,
|
|
96
|
+
now: float,
|
|
97
|
+
*,
|
|
98
|
+
grace_s: float = DEFAULT_SHED_GRACE_S,
|
|
99
|
+
force: bool = False,
|
|
100
|
+
limit: int = 3,
|
|
101
|
+
time_budget_s: float = 120.0,
|
|
102
|
+
until_ok: object = None,
|
|
103
|
+
clock: object = None,
|
|
104
|
+
) -> list[str]:
|
|
105
|
+
"""Shed due workspaces until `limit` is reached or `time_budget_s`
|
|
106
|
+
elapses, checking the budget between runs; a forced sweep also stops when
|
|
107
|
+
`until_ok` reports a healthy disk.
|
|
108
|
+
|
|
109
|
+
Bounded by BOTH a count (`limit`) and a wall-clock budget
|
|
110
|
+
(`time_budget_s`). Removing a workspace is `rm -rf` over the state
|
|
111
|
+
filesystem, tens of thousands of tiny files each on a networked FS, so an
|
|
112
|
+
unbounded batch inside a tick can run for many minutes and blow the tick's
|
|
113
|
+
own timeout (2026-09-03: a 50-run batch, and reading every record to find
|
|
114
|
+
candidates, killed the tick before it could publish). Discovery is ONE
|
|
115
|
+
directory read, sorted oldest-first by the timestamp embedded in each run
|
|
116
|
+
id (no per-entry stat, no record load); the loop then loads one record at
|
|
117
|
+
a time and checks the budget EACH step, so both are bounded. The backlog
|
|
118
|
+
drains over several ticks. With `until_ok` a forced sweep also stops as
|
|
119
|
+
soon as the disk reports healthy."""
|
|
120
|
+
monotonic = clock if callable(clock) else time.monotonic
|
|
121
|
+
start = monotonic()
|
|
122
|
+
runs_root = root / "runs"
|
|
123
|
+
try:
|
|
124
|
+
# ONE directory read (no per-entry stat), sorted oldest-first by the
|
|
125
|
+
# timestamp every run id carries (`<name>-YYYYMMDD-HHMMSS-...`), which
|
|
126
|
+
# is chronological across benchmark prefixes where a lexical sort is
|
|
127
|
+
# not; ids without one sort last so they never block the backlog.
|
|
128
|
+
run_ids = sorted(
|
|
129
|
+
(e.name for e in os.scandir(runs_root)),
|
|
130
|
+
key=lambda name: (_run_id_timestamp(name) or "99999999-999999", name),
|
|
131
|
+
)
|
|
132
|
+
except OSError:
|
|
133
|
+
return []
|
|
134
|
+
# One readdir + an in-memory sort is cheap even for thousands of run dirs,
|
|
135
|
+
# but never start shedding if it somehow overran the budget: the tick then
|
|
136
|
+
# spends the rest of its time publishing, not deleting.
|
|
137
|
+
if monotonic() - start >= time_budget_s:
|
|
138
|
+
return []
|
|
139
|
+
shed: list[str] = []
|
|
140
|
+
for run_id in run_ids:
|
|
141
|
+
if len(shed) >= limit:
|
|
142
|
+
break
|
|
143
|
+
if monotonic() - start >= time_budget_s:
|
|
144
|
+
log.info(
|
|
145
|
+
"housekeeping: time budget (%.0fs) reached; %d shed this tick",
|
|
146
|
+
time_budget_s,
|
|
147
|
+
len(shed),
|
|
148
|
+
)
|
|
149
|
+
break
|
|
150
|
+
if until_ok is not None and callable(until_ok) and until_ok():
|
|
151
|
+
break
|
|
152
|
+
try:
|
|
153
|
+
record = load_record(root, run_id)
|
|
154
|
+
except (OSError, ValueError, TypeError, KeyError):
|
|
155
|
+
continue
|
|
156
|
+
if _is_shed_candidate(root, record, now, grace_s, force) and shed_workspace(
|
|
157
|
+
root, record, now
|
|
158
|
+
):
|
|
159
|
+
shed.append(run_id)
|
|
160
|
+
if shed:
|
|
161
|
+
log.info(
|
|
162
|
+
"shed %d ended workspace(s)%s: %s",
|
|
163
|
+
len(shed),
|
|
164
|
+
" (forced)" if force else "",
|
|
165
|
+
", ".join(shed),
|
|
166
|
+
)
|
|
167
|
+
return shed
|
outerloop/init.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""`outerloop init` — the guided setup.
|
|
2
|
+
|
|
3
|
+
Collects placement (Slurm or local), the target repo, and bot auth, then writes
|
|
4
|
+
`~/.config/outerloop/.env` (plus the credential files), so a new adopter never
|
|
5
|
+
hand-edits config or reasons about which `OUTERLOOP_*` keys to set. Flags fill
|
|
6
|
+
answers non-interactively; anything left out is prompted for (a secret via
|
|
7
|
+
getpass, never echoed). Auth is the adopter's own GitHub App by default —
|
|
8
|
+
`--github-app`, one click via the manifest flow in `appmanifest.py` — with a PAT
|
|
9
|
+
as the fallback; either way `resolve_bot_auth` reads the result exactly as
|
|
10
|
+
`outerloop start` does. An existing `.env` is never overwritten without asking.
|
|
11
|
+
|
|
12
|
+
The config location is `cli.ENV_FILE`, the same file `start` reads — one source
|
|
13
|
+
of truth, so a rename of the config dir moves both together.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import getpass
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from outerloop.cli import ENV_FILE
|
|
28
|
+
|
|
29
|
+
CONFIG_DIR = ENV_FILE.parent
|
|
30
|
+
DEFAULT_PAT_FILE = CONFIG_DIR / "bot_pat"
|
|
31
|
+
API = "https://api.github.com"
|
|
32
|
+
# The climbing author's harnesses (attempt.py's --author-backend choices).
|
|
33
|
+
AUTHOR_BACKENDS = ("claude", "codex")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class InitAnswers:
|
|
38
|
+
compute: str # "slurm" | "local"
|
|
39
|
+
target: str # "owner/repo"
|
|
40
|
+
root: str = "" # Slurm state root on the shared filesystem
|
|
41
|
+
account: str = "" # Slurm account (required for Slurm)
|
|
42
|
+
partition: str = "" # Slurm partition (optional; unset -> Slurm default)
|
|
43
|
+
author_backend: str = "" # optional: the climbing author's harness
|
|
44
|
+
author_model: str = "" # optional
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render_env(a: InitAnswers, pat_file: str = "", *, app_file: str = "") -> str:
|
|
48
|
+
"""The `.env` body for these answers — only the keys that have a value, so
|
|
49
|
+
the file stays minimal and every line means something. Ordered placement →
|
|
50
|
+
target → auth → author to read top-to-bottom like the setup itself. Auth is
|
|
51
|
+
an App file (`--github-app`) or a PAT file, never both."""
|
|
52
|
+
lines = [f"OUTERLOOP_COMPUTE={a.compute}"]
|
|
53
|
+
if a.compute == "slurm":
|
|
54
|
+
lines.append(f"OUTERLOOP_ROOT={a.root}")
|
|
55
|
+
lines.append(f"OUTERLOOP_ACCOUNT={a.account}")
|
|
56
|
+
if a.partition: # optional: unset lets Slurm pick its default partition
|
|
57
|
+
lines.append(f"OUTERLOOP_PARTITION={a.partition}")
|
|
58
|
+
lines.append(f"OUTERLOOP_TARGET={a.target}")
|
|
59
|
+
if app_file:
|
|
60
|
+
lines.append(f"OUTERLOOP_GITHUB_APP_FILE={app_file}")
|
|
61
|
+
elif pat_file:
|
|
62
|
+
lines.append(f"OUTERLOOP_PAT_FILE={pat_file}")
|
|
63
|
+
if a.author_backend:
|
|
64
|
+
lines.append(f"OUTERLOOP_AUTHOR_BACKEND={a.author_backend}")
|
|
65
|
+
if a.author_model:
|
|
66
|
+
lines.append(f"OUTERLOOP_AUTHOR_MODEL={a.author_model}")
|
|
67
|
+
return "\n".join(lines) + "\n"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def write_config(
|
|
71
|
+
a: InitAnswers, token: str, pat_file: str, *, config_dir: Path = CONFIG_DIR
|
|
72
|
+
) -> tuple[Path, Path | None]:
|
|
73
|
+
"""Write the PAT file (only when a token is pasted) and the `.env`, both
|
|
74
|
+
owner-only (0600) — `start`/`tick_deploy` refuse a group/world-readable
|
|
75
|
+
`.env`, and a token file must never be wider. Returns (env_path, pat_path)."""
|
|
76
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
written_pat: Path | None = None
|
|
78
|
+
if token:
|
|
79
|
+
pat_path = config_dir / DEFAULT_PAT_FILE.name
|
|
80
|
+
# printf-style: no trailing newline — the deploy `cat`s this file into
|
|
81
|
+
# the git credential, and a trailing newline rides along and breaks auth.
|
|
82
|
+
pat_path.write_text(token)
|
|
83
|
+
pat_path.chmod(0o600)
|
|
84
|
+
written_pat = pat_path
|
|
85
|
+
pat_file = str(pat_path)
|
|
86
|
+
env_path = config_dir / ENV_FILE.name
|
|
87
|
+
env_path.write_text(render_env(a, pat_file))
|
|
88
|
+
env_path.chmod(0o600)
|
|
89
|
+
return env_path, written_pat
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _check_repo_access(token: str, target: str) -> str:
|
|
93
|
+
"""Can this token write the target repo? "" on success, else a short reason.
|
|
94
|
+
Never raises — a check failure is a warning, not a reason to abandon config."""
|
|
95
|
+
req = urllib.request.Request(
|
|
96
|
+
f"{API}/repos/{target}",
|
|
97
|
+
headers={"Authorization": f"token {token}", "Accept": "application/vnd.github+json"},
|
|
98
|
+
)
|
|
99
|
+
try:
|
|
100
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
101
|
+
body = json.loads(resp.read())
|
|
102
|
+
perms = body.get("permissions") or {}
|
|
103
|
+
if not perms.get("push"):
|
|
104
|
+
return f"reaches {target} but lacks write access (it opens PRs)"
|
|
105
|
+
return ""
|
|
106
|
+
except urllib.error.HTTPError as exc:
|
|
107
|
+
if exc.code == 404:
|
|
108
|
+
return f"{target} not found, or the token cannot see it"
|
|
109
|
+
return f"GitHub returned {exc.code} for {target}"
|
|
110
|
+
except urllib.error.URLError as exc:
|
|
111
|
+
return f"could not reach GitHub: {exc.reason}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def validate_pat(pat_file: str, target: str) -> str:
|
|
115
|
+
"""Best-effort: can the PAT in `pat_file` write the target repo?"""
|
|
116
|
+
try:
|
|
117
|
+
token = Path(pat_file).expanduser().read_text().strip()
|
|
118
|
+
except OSError as exc:
|
|
119
|
+
return f"could not read {pat_file}: {exc}"
|
|
120
|
+
if not token:
|
|
121
|
+
return f"{pat_file} is empty"
|
|
122
|
+
return _check_repo_access(token, target)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _ask(prompt: str, default: str = "", *, required: bool = False) -> str:
|
|
126
|
+
"""One interactive prompt with an optional default; re-asks while a required
|
|
127
|
+
answer is blank."""
|
|
128
|
+
suffix = f" [{default}]" if default else ""
|
|
129
|
+
while True:
|
|
130
|
+
got = input(f"{prompt}{suffix}: ").strip() or default
|
|
131
|
+
if got or not required:
|
|
132
|
+
return got
|
|
133
|
+
print(" (required)")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _collect(args: argparse.Namespace, interactive: bool) -> tuple[InitAnswers, str]:
|
|
137
|
+
"""Merge flags with prompts (when interactive) into answers + a PAT-file
|
|
138
|
+
path. `args.pat_file` names an existing file; otherwise, interactively, a
|
|
139
|
+
pasted token is returned separately to be written 0600."""
|
|
140
|
+
compute = args.compute or (_ask("Compute: slurm or local", "slurm") if interactive else "slurm")
|
|
141
|
+
compute = compute.lower()
|
|
142
|
+
target = args.target or (_ask("Target repo (owner/repo)", required=True) if interactive else "")
|
|
143
|
+
root = account = partition = ""
|
|
144
|
+
if compute == "slurm":
|
|
145
|
+
root = args.root or (
|
|
146
|
+
_ask("Slurm state root (shared filesystem)", required=True) if interactive else ""
|
|
147
|
+
)
|
|
148
|
+
account = args.account or (_ask("Slurm account", required=True) if interactive else "")
|
|
149
|
+
partition = args.partition or (
|
|
150
|
+
_ask("Slurm partition (blank = Slurm default; a,b for a list)") if interactive else ""
|
|
151
|
+
)
|
|
152
|
+
# Author config is part of the full setup, not the focused --github-app run
|
|
153
|
+
# (that one is about auth). When asked, offer the fixed set, not a blank.
|
|
154
|
+
ask_author = interactive and not args.github_app
|
|
155
|
+
backend = args.author_backend or (
|
|
156
|
+
_ask("Author backend (claude or codex)", "claude") if ask_author else ""
|
|
157
|
+
)
|
|
158
|
+
model = args.author_model or (
|
|
159
|
+
_ask("Author model (blank = the backend's default)") if ask_author else ""
|
|
160
|
+
)
|
|
161
|
+
answers = InitAnswers(
|
|
162
|
+
compute=compute,
|
|
163
|
+
target=target,
|
|
164
|
+
root=root,
|
|
165
|
+
account=account,
|
|
166
|
+
partition=partition,
|
|
167
|
+
author_backend=backend,
|
|
168
|
+
author_model=model,
|
|
169
|
+
)
|
|
170
|
+
return answers, (args.pat_file or "")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _github_app_setup(answers: InitAnswers, app_name: str, org: str) -> int:
|
|
174
|
+
"""The `--github-app` path: create the adopter's own App via the manifest
|
|
175
|
+
flow, write its creds, help install it, then point the .env at the App file.
|
|
176
|
+
Interactive by nature (a browser click + install), so no `--yes` variant."""
|
|
177
|
+
from outerloop import appmanifest
|
|
178
|
+
|
|
179
|
+
owner = org or answers.target.split("/")[0]
|
|
180
|
+
name = app_name or f"outerloop-{owner}"[:34] # GitHub caps App names at 34
|
|
181
|
+
code = appmanifest.request_manifest_code(
|
|
182
|
+
name, "https://github.com/outerloop-science/outerloop", org
|
|
183
|
+
)
|
|
184
|
+
if not code:
|
|
185
|
+
print("outerloop init: no code entered — nothing created", file=sys.stderr)
|
|
186
|
+
return 1
|
|
187
|
+
try:
|
|
188
|
+
conversion = appmanifest.convert_manifest(code)
|
|
189
|
+
except ValueError as exc:
|
|
190
|
+
print(f"outerloop init: {exc}", file=sys.stderr)
|
|
191
|
+
return 1
|
|
192
|
+
pem_path, app_json = appmanifest.save_app_creds(conversion, CONFIG_DIR)
|
|
193
|
+
print(f"created App '{conversion['slug']}' — wrote {app_json} and {pem_path} (0600)")
|
|
194
|
+
print(f"install it on {answers.target}: {appmanifest.install_url(conversion)}")
|
|
195
|
+
input("press Enter once you've installed the App… ")
|
|
196
|
+
iid = appmanifest.capture_installation_id(int(conversion["id"]), pem_path, owner)
|
|
197
|
+
if iid:
|
|
198
|
+
appmanifest.set_installation_id(app_json, iid)
|
|
199
|
+
print(f" installation id {iid} recorded")
|
|
200
|
+
# Self-verify end to end: mint a real installation token and check it can
|
|
201
|
+
# write the target — this is what confirms the whole flow actually worked.
|
|
202
|
+
from outerloop.appauth import app_provider_from_file
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
token = app_provider_from_file(app_json).token()
|
|
206
|
+
problem = _check_repo_access(token, answers.target)
|
|
207
|
+
print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
|
|
208
|
+
except Exception as exc: # a check failure is a warning, never fails setup
|
|
209
|
+
print(f" auth check: WARNING — could not mint a token: {exc}")
|
|
210
|
+
else:
|
|
211
|
+
print(
|
|
212
|
+
f" no installation found yet — install the App, then set installation_id in {app_json}"
|
|
213
|
+
)
|
|
214
|
+
env_path = CONFIG_DIR / ENV_FILE.name
|
|
215
|
+
env_path.write_text(render_env(answers, app_file=str(app_json)))
|
|
216
|
+
env_path.chmod(0o600)
|
|
217
|
+
print(f"wrote {env_path}")
|
|
218
|
+
print("next: outerloop start")
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def main(argv: list[str] | None = None) -> int:
|
|
223
|
+
parser = argparse.ArgumentParser(
|
|
224
|
+
prog="outerloop init", description="guided setup for outerloop"
|
|
225
|
+
)
|
|
226
|
+
parser.add_argument("--compute", choices=["slurm", "local"], help="where the loop runs")
|
|
227
|
+
parser.add_argument("--target", help="the repo the agents work on, owner/repo")
|
|
228
|
+
parser.add_argument("--root", help="Slurm state root on the shared filesystem")
|
|
229
|
+
parser.add_argument("--account", help="Slurm account")
|
|
230
|
+
parser.add_argument(
|
|
231
|
+
"--partition", help="Slurm partition (optional; blank = default; a,b = list)"
|
|
232
|
+
)
|
|
233
|
+
parser.add_argument(
|
|
234
|
+
"--pat-file", dest="pat_file", help="path to an existing file holding a PAT"
|
|
235
|
+
)
|
|
236
|
+
parser.add_argument(
|
|
237
|
+
"--github-app",
|
|
238
|
+
dest="github_app",
|
|
239
|
+
action="store_true",
|
|
240
|
+
help="create your own GitHub App in a browser (one click) instead of a PAT",
|
|
241
|
+
)
|
|
242
|
+
parser.add_argument("--app-name", dest="app_name", help="name for the created GitHub App")
|
|
243
|
+
parser.add_argument("--org", help="create the App under this org (default: your account)")
|
|
244
|
+
parser.add_argument("--author-backend", dest="author_backend", help="climbing author's backend")
|
|
245
|
+
parser.add_argument("--author-model", dest="author_model", help="climbing author's model")
|
|
246
|
+
parser.add_argument(
|
|
247
|
+
"--yes", "-y", action="store_true", help="non-interactive: use flags, do not prompt"
|
|
248
|
+
)
|
|
249
|
+
parser.add_argument(
|
|
250
|
+
"--force", action="store_true", help="overwrite an existing config without asking"
|
|
251
|
+
)
|
|
252
|
+
args = parser.parse_args(sys.argv[2:] if argv is None else argv)
|
|
253
|
+
interactive = not args.yes
|
|
254
|
+
|
|
255
|
+
answers, pat_file = _collect(args, interactive)
|
|
256
|
+
if not answers.target:
|
|
257
|
+
print("outerloop init: a target repo is required (--target owner/repo)", file=sys.stderr)
|
|
258
|
+
return 2
|
|
259
|
+
if answers.compute == "slurm" and not (answers.root and answers.account):
|
|
260
|
+
print("outerloop init: Slurm needs --root and --account", file=sys.stderr)
|
|
261
|
+
return 2
|
|
262
|
+
if answers.author_backend and answers.author_backend not in AUTHOR_BACKENDS:
|
|
263
|
+
print(
|
|
264
|
+
f"outerloop init: author backend must be one of {', '.join(AUTHOR_BACKENDS)}",
|
|
265
|
+
file=sys.stderr,
|
|
266
|
+
)
|
|
267
|
+
return 2
|
|
268
|
+
|
|
269
|
+
# Never clobber a working setup silently: a re-run of init on a configured
|
|
270
|
+
# machine must ask (or be told --force). Checked before any App is created.
|
|
271
|
+
env_path = CONFIG_DIR / ENV_FILE.name
|
|
272
|
+
if env_path.exists() and not args.force:
|
|
273
|
+
if not interactive:
|
|
274
|
+
print(f"outerloop init: {env_path} exists; pass --force to overwrite", file=sys.stderr)
|
|
275
|
+
return 1
|
|
276
|
+
if not _ask(f"{env_path} exists — overwrite it? (y/N)", "n").lower().startswith("y"):
|
|
277
|
+
print(
|
|
278
|
+
"outerloop init: kept the existing config (--force skips this check)",
|
|
279
|
+
file=sys.stderr,
|
|
280
|
+
)
|
|
281
|
+
return 1
|
|
282
|
+
|
|
283
|
+
# The App is the recommended credential (scoped, revocable, no plaintext
|
|
284
|
+
# token); the PAT is the fallback. Offer it first when interactive.
|
|
285
|
+
if not args.github_app and not pat_file and interactive:
|
|
286
|
+
choice = _ask(
|
|
287
|
+
"Auth — [app] create your own GitHub App (recommended) or [pat] paste a token",
|
|
288
|
+
"app",
|
|
289
|
+
).lower()
|
|
290
|
+
if choice.startswith("a"):
|
|
291
|
+
args.github_app = True
|
|
292
|
+
if args.github_app:
|
|
293
|
+
return _github_app_setup(answers, args.app_name or "", args.org or "")
|
|
294
|
+
|
|
295
|
+
token = ""
|
|
296
|
+
if not pat_file and interactive:
|
|
297
|
+
# re-collect only the token here so _collect stays pure of getpass in tests
|
|
298
|
+
token = getpass.getpass(
|
|
299
|
+
"Paste a GitHub PAT with write access to the target (hidden; blank to skip): "
|
|
300
|
+
).strip()
|
|
301
|
+
|
|
302
|
+
env_path, pat_path = write_config(answers, token, pat_file, config_dir=CONFIG_DIR)
|
|
303
|
+
print(f"wrote {env_path}")
|
|
304
|
+
if pat_path:
|
|
305
|
+
print(f"wrote {pat_path} (0600)")
|
|
306
|
+
effective_pat = str(pat_path) if pat_path else pat_file
|
|
307
|
+
if effective_pat:
|
|
308
|
+
problem = validate_pat(effective_pat, answers.target)
|
|
309
|
+
print(f" auth check: {'ok' if not problem else 'WARNING — ' + problem}")
|
|
310
|
+
else:
|
|
311
|
+
print(" no PAT set — add OUTERLOOP_PAT_FILE before the agents can open PRs")
|
|
312
|
+
print("next: outerloop start")
|
|
313
|
+
return 0
|
outerloop/intake.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""The requested lane: maintainer issues become runs.
|
|
2
|
+
|
|
3
|
+
An open issue on the target repo qualifies when its author carries repo
|
|
4
|
+
standing (same association gate as review comments). The tick claims at most
|
|
5
|
+
one per cycle by commenting a claim marker, then submits a climb job whose
|
|
6
|
+
task carries the issue text data-fenced; the resulting PR references the
|
|
7
|
+
issue, and the run's report lands back on the issue thread — the loop closes
|
|
8
|
+
with whoever asked (docs/design/architecture.md, "The life of a run").
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from outerloop.brief import MAX_TASK_CHARS, _cap, _fence
|
|
17
|
+
from outerloop.contract import Contract
|
|
18
|
+
from outerloop.followup import QUALIFYING_ASSOCIATIONS
|
|
19
|
+
from outerloop.github import is_own_login
|
|
20
|
+
from outerloop.markers import has_label, has_marker, label_name, marker
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
CLAIM_MARKER = marker("claimed")
|
|
25
|
+
# Posted (by the bot only) to undo a claim whose run never started — a failed
|
|
26
|
+
# submit must not strand the issue, since the claim scan skips claimed issues.
|
|
27
|
+
RELEASE_MARKER = marker("claim-released")
|
|
28
|
+
# Claim attempts per issue before intake gives up on it: a durable submit
|
|
29
|
+
# failure must not claim/release (and comment) forever. Same idea as the
|
|
30
|
+
# steward lane's MAX_STEWARD_ATTEMPTS.
|
|
31
|
+
MAX_INTAKE_ATTEMPTS = 3
|
|
32
|
+
# steward work orders carry this label; they are the STEWARD lane's,
|
|
33
|
+
# never the solver's (a solver climb cannot touch env paths anyway)
|
|
34
|
+
STEWARD_LABEL = label_name("steward")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class IssueTask:
|
|
39
|
+
number: int
|
|
40
|
+
title: str
|
|
41
|
+
body: str
|
|
42
|
+
author: str
|
|
43
|
+
benchmark: str # inferred from the issue text against the contract
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def infer_benchmark(text: str, contract: Contract) -> str:
|
|
47
|
+
"""The single contract benchmark the issue names, or "" if not exactly
|
|
48
|
+
one — ambiguity is a human problem, not a guess."""
|
|
49
|
+
lowered = text.casefold()
|
|
50
|
+
named = [b.name for b in contract.benchmarks if b.name.casefold() in lowered]
|
|
51
|
+
return named[0] if len(named) == 1 else ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def qualifying_issue(issue: dict, bot_login: str) -> bool:
|
|
55
|
+
author = str((issue.get("user") or {}).get("login", ""))
|
|
56
|
+
if is_own_login(author, bot_login):
|
|
57
|
+
return False # the kernel's own issues (research log, alarms) are never orders
|
|
58
|
+
if str(issue.get("author_association", "")) not in QUALIFYING_ASSOCIATIONS:
|
|
59
|
+
return False
|
|
60
|
+
return bool(str(issue.get("title") or "").strip())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def pick_issue(github, repo: str, contract: Contract, bot_login: str) -> IssueTask | None:
|
|
64
|
+
"""The oldest qualifying, unclaimed issue that names exactly one
|
|
65
|
+
benchmark. At most one — intake is deliberately slow."""
|
|
66
|
+
if not bot_login.strip():
|
|
67
|
+
# fail closed like the steward picker: with no identity the claim
|
|
68
|
+
# scan below would see NO claims and re-claim every tick — an
|
|
69
|
+
# unbounded paid loop
|
|
70
|
+
log.warning("pick_issue: bot_login is blank; intake lane sits out")
|
|
71
|
+
return None
|
|
72
|
+
issues = sorted(github.list_open_issues(repo), key=lambda i: i.get("number", 0))
|
|
73
|
+
for issue in issues:
|
|
74
|
+
labels = {
|
|
75
|
+
str(label.get("name", "")).casefold()
|
|
76
|
+
for label in issue.get("labels", [])
|
|
77
|
+
if isinstance(label, dict)
|
|
78
|
+
}
|
|
79
|
+
if has_label(labels, "steward"):
|
|
80
|
+
continue # the steward lane's, never the solver's
|
|
81
|
+
if not qualifying_issue(issue, bot_login):
|
|
82
|
+
continue
|
|
83
|
+
number = int(issue["number"])
|
|
84
|
+
claimed = False
|
|
85
|
+
attempts = 0
|
|
86
|
+
for c in github.list_comments(repo, number):
|
|
87
|
+
author = str((c.get("user") or {}).get("login", ""))
|
|
88
|
+
if not is_own_login(author, bot_login):
|
|
89
|
+
continue # only the bot's own markers count — no forged releases
|
|
90
|
+
body = str(c.get("body", ""))
|
|
91
|
+
if has_marker(body, "claimed"):
|
|
92
|
+
claimed = True
|
|
93
|
+
attempts += 1
|
|
94
|
+
if has_marker(body, "claim-released"):
|
|
95
|
+
claimed = False
|
|
96
|
+
if claimed:
|
|
97
|
+
continue # already claimed by a run
|
|
98
|
+
if attempts >= MAX_INTAKE_ATTEMPTS:
|
|
99
|
+
log.info("issue #%s burned %d claim attempts; needs a human look", number, attempts)
|
|
100
|
+
continue
|
|
101
|
+
text = f"{issue.get('title', '')}\n{issue.get('body') or ''}"
|
|
102
|
+
benchmark = infer_benchmark(text, contract)
|
|
103
|
+
if not benchmark:
|
|
104
|
+
log.info("issue #%s names zero or several benchmarks; skipping", number)
|
|
105
|
+
continue
|
|
106
|
+
return IssueTask(
|
|
107
|
+
number=number,
|
|
108
|
+
title=str(issue.get("title") or ""),
|
|
109
|
+
body=str(issue.get("body") or ""),
|
|
110
|
+
author=str((issue.get("user") or {}).get("login", "")),
|
|
111
|
+
benchmark=benchmark,
|
|
112
|
+
)
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def issue_hypothesis(task: IssueTask) -> str:
|
|
117
|
+
"""The task text for the brief: the maintainer's ask, data-fenced.
|
|
118
|
+
|
|
119
|
+
The author passed the standing gate, so the REQUEST is legitimate; the
|
|
120
|
+
fence marks where quoted text ends and the harness's authority resumes.
|
|
121
|
+
"""
|
|
122
|
+
quoted = _cap(f"{task.title}\n\n{task.body}".strip(), MAX_TASK_CHARS - 400)
|
|
123
|
+
fence = _fence(quoted)
|
|
124
|
+
return (
|
|
125
|
+
f"A maintainer (@{task.author}) opened issue #{task.number} requesting "
|
|
126
|
+
f"work on the `{task.benchmark}` benchmark. Their request:\n"
|
|
127
|
+
f"{fence}\n{quoted}\n{fence}\n"
|
|
128
|
+
"Address the request's substance within the contract's rules."
|
|
129
|
+
)
|