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.
Files changed (52) hide show
  1. outerloop/__init__.py +18 -0
  2. outerloop/__main__.py +3 -0
  3. outerloop/appauth.py +213 -0
  4. outerloop/appmanifest.py +198 -0
  5. outerloop/attempt.py +3481 -0
  6. outerloop/brief.py +515 -0
  7. outerloop/cli.py +439 -0
  8. outerloop/climbboard.py +1145 -0
  9. outerloop/compute.py +482 -0
  10. outerloop/contract.py +483 -0
  11. outerloop/contract_cli.py +63 -0
  12. outerloop/disk.py +164 -0
  13. outerloop/dispatch.py +586 -0
  14. outerloop/followup.py +2143 -0
  15. outerloop/github.py +1486 -0
  16. outerloop/harness.py +1449 -0
  17. outerloop/housekeeping.py +167 -0
  18. outerloop/init.py +313 -0
  19. outerloop/intake.py +129 -0
  20. outerloop/limits.py +80 -0
  21. outerloop/markers.py +48 -0
  22. outerloop/measure.py +523 -0
  23. outerloop/orchestrator.py +1901 -0
  24. outerloop/panel.py +188 -0
  25. outerloop/paths.py +27 -0
  26. outerloop/posting.py +160 -0
  27. outerloop/progress.py +170 -0
  28. outerloop/py.typed +0 -0
  29. outerloop/review.py +611 -0
  30. outerloop/review_agent.py +263 -0
  31. outerloop/review_agent_cli.py +209 -0
  32. outerloop/review_post_cli.py +162 -0
  33. outerloop/review_summarize_cli.py +163 -0
  34. outerloop/role_runner.py +229 -0
  35. outerloop/roles.py +247 -0
  36. outerloop/rolespec.py +89 -0
  37. outerloop/runstate.py +385 -0
  38. outerloop/steward.py +852 -0
  39. outerloop/style.py +12 -0
  40. outerloop/syscall.py +977 -0
  41. outerloop/syscall_cli.py +531 -0
  42. outerloop/tick.py +3166 -0
  43. outerloop/verifier.py +403 -0
  44. outerloop/verify_agent.py +149 -0
  45. outerloop/verify_agent_cli.py +95 -0
  46. outerloop/verify_post_cli.py +116 -0
  47. outerloop_science-0.1.0.dev0.dist-info/METADATA +145 -0
  48. outerloop_science-0.1.0.dev0.dist-info/RECORD +52 -0
  49. outerloop_science-0.1.0.dev0.dist-info/WHEEL +4 -0
  50. outerloop_science-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  51. outerloop_science-0.1.0.dev0.dist-info/licenses/LICENSE +202 -0
  52. 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
+ )