outerloop-science 0.1.0__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 +230 -0
- outerloop/appmanifest.py +203 -0
- outerloop/attempt.py +3784 -0
- outerloop/brief.py +528 -0
- outerloop/cli.py +621 -0
- outerloop/climbboard.py +1395 -0
- outerloop/compute.py +654 -0
- outerloop/contract.py +492 -0
- outerloop/contract_cli.py +63 -0
- outerloop/disk.py +164 -0
- outerloop/dispatch.py +631 -0
- outerloop/evalcache.py +147 -0
- outerloop/followup.py +2172 -0
- outerloop/github.py +1531 -0
- outerloop/harness.py +1435 -0
- outerloop/housekeeping.py +151 -0
- outerloop/image.py +368 -0
- outerloop/init.py +744 -0
- outerloop/intake.py +126 -0
- outerloop/launchlog.py +239 -0
- outerloop/limits.py +80 -0
- outerloop/maintain.py +353 -0
- outerloop/maintain_agent_cli.py +81 -0
- outerloop/maintain_post_cli.py +140 -0
- outerloop/markers.py +48 -0
- outerloop/measure.py +529 -0
- outerloop/orchestrator.py +2011 -0
- outerloop/panel.py +188 -0
- outerloop/paths.py +40 -0
- outerloop/posting.py +160 -0
- outerloop/progress.py +170 -0
- outerloop/py.typed +0 -0
- outerloop/review.py +615 -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 +165 -0
- outerloop/role_runner.py +229 -0
- outerloop/roles.py +274 -0
- outerloop/rolespec.py +91 -0
- outerloop/runstate.py +385 -0
- outerloop/steward.py +845 -0
- outerloop/style.py +12 -0
- outerloop/syscall.py +1192 -0
- outerloop/syscall_cli.py +762 -0
- outerloop/tick.py +3422 -0
- outerloop/verifier.py +403 -0
- outerloop/verify_agent.py +151 -0
- outerloop/verify_agent_cli.py +95 -0
- outerloop/verify_post_cli.py +116 -0
- outerloop/watcher.py +203 -0
- outerloop_science-0.1.0.dist-info/METADATA +152 -0
- outerloop_science-0.1.0.dist-info/RECORD +59 -0
- outerloop_science-0.1.0.dist-info/WHEEL +4 -0
- outerloop_science-0.1.0.dist-info/entry_points.txt +2 -0
- outerloop_science-0.1.0.dist-info/licenses/LICENSE +202 -0
- outerloop_science-0.1.0.dist-info/licenses/NOTICE +5 -0
outerloop/cli.py
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"""The one launch command: `outerloop start`.
|
|
2
|
+
|
|
3
|
+
With `sbatch` on PATH it submits the resident tick
|
|
4
|
+
(docs/design/resident-tick.md) and returns; without it, or with
|
|
5
|
+
OUTERLOOP_COMPUTE=local, it runs the local loop in the foreground.
|
|
6
|
+
Settings come from flags, then the process environment, then
|
|
7
|
+
~/.config/outerloop/.env, read once here at launch. The running chain
|
|
8
|
+
never takes identity or placement from that file (tick_deploy.sh reads an
|
|
9
|
+
allowlist of author knobs per tick), so editing it later cannot move a chain.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import os
|
|
16
|
+
import shlex
|
|
17
|
+
import shutil
|
|
18
|
+
import stat
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from outerloop import paths
|
|
26
|
+
|
|
27
|
+
RESIDENT_JOB_NAME = "outerloop-resident"
|
|
28
|
+
# A resident submitted before the rename. Slurm's singleton serializes jobs by
|
|
29
|
+
# NAME, so `start` must refuse while one of these is queued or running (two
|
|
30
|
+
# residents on one root is what the singleton prevents). Dropped in the
|
|
31
|
+
# release after 0.1.
|
|
32
|
+
LEGACY_RESIDENT_JOB_NAME = "autoresearch-resident"
|
|
33
|
+
DEFAULT_RESIDENT_MINUTES = 360 # cpu_short's ceiling on Torch; the loop hands over to itself
|
|
34
|
+
DEFAULT_LOCAL_ROOT = Path.home() / ".outerloop"
|
|
35
|
+
LEGACY_LOCAL_ROOT_NAME = ".autoresearch" # pre-rename; honored until the release after 0.1
|
|
36
|
+
ENV_FILE = paths.ENV_FILE # ~/.config/outerloop/.env, or the pre-rename dir (see paths.py)
|
|
37
|
+
|
|
38
|
+
# What start itself decides from: mode, placement, root, cadence, walltime.
|
|
39
|
+
START_KEYS = (
|
|
40
|
+
"OUTERLOOP_COMPUTE",
|
|
41
|
+
"OUTERLOOP_ROOT",
|
|
42
|
+
"OUTERLOOP_ACCOUNT",
|
|
43
|
+
"OUTERLOOP_PARTITION",
|
|
44
|
+
"OUTERLOOP_CADENCE_MIN",
|
|
45
|
+
"OUTERLOOP_RESIDENT_MINUTES",
|
|
46
|
+
"OUTERLOOP_PAT_FILE",
|
|
47
|
+
)
|
|
48
|
+
# The author knobs the chain's deploy step exports from .env every tick. The
|
|
49
|
+
# local loop has no deploy step, so start exports them once at launch; a test
|
|
50
|
+
# keeps this list identical to tick_deploy.sh's.
|
|
51
|
+
TICK_ENV_KEYS = (
|
|
52
|
+
"OUTERLOOP_AUTHOR_BACKEND",
|
|
53
|
+
"OUTERLOOP_AUTHOR_MODEL",
|
|
54
|
+
"OUTERLOOP_CLAUDE_BIN",
|
|
55
|
+
"OUTERLOOP_CODEX_BIN",
|
|
56
|
+
"OUTERLOOP_CODEX_KEY_FILE",
|
|
57
|
+
"OUTERLOOP_CLAUDE_KEY_FILE",
|
|
58
|
+
"OUTERLOOP_HARNESS_KEY_FILE",
|
|
59
|
+
"OUTERLOOP_VERTEX_PROJECT",
|
|
60
|
+
"OUTERLOOP_VERTEX_REGION",
|
|
61
|
+
"OUTERLOOP_VERTEX_ADC",
|
|
62
|
+
"OUTERLOOP_TARGET",
|
|
63
|
+
"OUTERLOOP_GITHUB_APP_FILE",
|
|
64
|
+
"OUTERLOOP_BOT_LOGIN",
|
|
65
|
+
"OUTERLOOP_BOT_ALIASES",
|
|
66
|
+
"OUTERLOOP_GPU_PARTITION",
|
|
67
|
+
"OUTERLOOP_GPU_ACCOUNT",
|
|
68
|
+
"OUTERLOOP_IMAGE",
|
|
69
|
+
"OUTERLOOP_PANEL",
|
|
70
|
+
"OUTERLOOP_PANEL_KEY_FILE",
|
|
71
|
+
"OUTERLOOP_PANEL_CODEX_KEY_FILE",
|
|
72
|
+
"OUTERLOOP_PANEL_HERMES_KEY_FILE",
|
|
73
|
+
"REVIEW_HERMES_REPO",
|
|
74
|
+
"REVIEW_HERMES_PROVIDER",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class StartError(Exception):
|
|
79
|
+
"""A start that cannot proceed; the message is the whole diagnosis."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def env_file_values(path: Path = ENV_FILE, keys: tuple[str, ...] = START_KEYS) -> dict[str, str]:
|
|
83
|
+
"""`keys` from the operator's .env under the deploy step's trust rule: the
|
|
84
|
+
file must be ours and not group/world-writable, or it is refused. Last
|
|
85
|
+
assignment wins; surrounding quotes and a CR are stripped; a key set to
|
|
86
|
+
an empty value is present (an off-switch), an absent key is absent.
|
|
87
|
+
No file: nothing."""
|
|
88
|
+
try:
|
|
89
|
+
st = path.stat()
|
|
90
|
+
except OSError:
|
|
91
|
+
return {}
|
|
92
|
+
if st.st_uid != os.getuid() or st.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
|
|
93
|
+
raise StartError(
|
|
94
|
+
f"refusing to read {path}: it must be owned by you and not group/world-writable"
|
|
95
|
+
)
|
|
96
|
+
try:
|
|
97
|
+
text = path.read_text()
|
|
98
|
+
except OSError as e:
|
|
99
|
+
raise StartError(f"cannot read {path}: {e}") from None
|
|
100
|
+
out: dict[str, str] = {}
|
|
101
|
+
canonical: set[str] = set() # keys set by their OUTERLOOP_ spelling
|
|
102
|
+
for raw in text.splitlines():
|
|
103
|
+
line = raw.rstrip("\r").strip()
|
|
104
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
105
|
+
continue
|
|
106
|
+
key, value = line.split("=", 1)
|
|
107
|
+
key = key.strip()
|
|
108
|
+
legacy = key.startswith("AUTORESEARCH_") # the pre-rename name, one more release
|
|
109
|
+
if legacy:
|
|
110
|
+
key = "OUTERLOOP_" + key[len("AUTORESEARCH_") :]
|
|
111
|
+
if key not in keys:
|
|
112
|
+
continue
|
|
113
|
+
if legacy and key in canonical:
|
|
114
|
+
continue # the OUTERLOOP_ spelling wins whatever the file order
|
|
115
|
+
value = value.strip()
|
|
116
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
117
|
+
value = value[1:-1]
|
|
118
|
+
out[key] = value
|
|
119
|
+
if not legacy:
|
|
120
|
+
canonical.add(key)
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class StartPlan:
|
|
126
|
+
mode: str # "slurm" | "local"
|
|
127
|
+
root: Path
|
|
128
|
+
home: Path = Path(".")
|
|
129
|
+
account: str = ""
|
|
130
|
+
partition: str = ""
|
|
131
|
+
cadence_min: str = ""
|
|
132
|
+
resident_minutes: int = DEFAULT_RESIDENT_MINUTES
|
|
133
|
+
pat_file: str = ""
|
|
134
|
+
|
|
135
|
+
def export_env(self) -> dict[str, str]:
|
|
136
|
+
"""The knobs the resident job needs. They ride the inherited environment
|
|
137
|
+
(sbatch `--export=ALL`), NOT a comma-joined `--export=K=V,K=V` list — so a
|
|
138
|
+
value may itself contain a comma (e.g. a multi-partition `a,b`, which Slurm
|
|
139
|
+
reads as "whichever frees up first") without corrupting the export
|
|
140
|
+
delimiter. start() merges these into the environment it hands sbatch."""
|
|
141
|
+
env = {
|
|
142
|
+
"OUTERLOOP_RESIDENT": "1",
|
|
143
|
+
"OUTERLOOP_HOME": str(self.home),
|
|
144
|
+
"OUTERLOOP_ROOT": str(self.root),
|
|
145
|
+
"OUTERLOOP_RESIDENT_MINUTES": str(self.resident_minutes), # successors reuse it
|
|
146
|
+
}
|
|
147
|
+
if self.account:
|
|
148
|
+
env["OUTERLOOP_ACCOUNT"] = self.account
|
|
149
|
+
if self.partition:
|
|
150
|
+
env["OUTERLOOP_PARTITION"] = self.partition
|
|
151
|
+
if self.cadence_min:
|
|
152
|
+
env["OUTERLOOP_CADENCE_MIN"] = self.cadence_min
|
|
153
|
+
if self.pat_file:
|
|
154
|
+
env["OUTERLOOP_PAT_FILE"] = self.pat_file
|
|
155
|
+
return env
|
|
156
|
+
|
|
157
|
+
def command(self) -> list[str]:
|
|
158
|
+
if self.mode == "local":
|
|
159
|
+
return [sys.executable, "-m", "outerloop.tick", "--root", str(self.root), "--loop"]
|
|
160
|
+
argv = [
|
|
161
|
+
"sbatch",
|
|
162
|
+
"--parsable",
|
|
163
|
+
"--dependency=singleton", # two starts can both submit; only one ever runs
|
|
164
|
+
f"--time={self.resident_minutes}",
|
|
165
|
+
f"--job-name={RESIDENT_JOB_NAME}",
|
|
166
|
+
]
|
|
167
|
+
if self.account: # unset bills the caller's default Slurm association
|
|
168
|
+
argv.append(f"--account={self.account}")
|
|
169
|
+
if self.partition: # unset lets Slurm choose its default partition
|
|
170
|
+
argv.append(f"--partition={self.partition}")
|
|
171
|
+
# --export=ALL carries export_env() from the inherited environment; a
|
|
172
|
+
# comma-joined K=V list here would break on any value containing a comma.
|
|
173
|
+
argv += ["--export=ALL", str(self.home / "scripts" / "tick_chain.sbatch")]
|
|
174
|
+
return argv
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _setting(key: str, flag: str, environ: dict[str, str], from_file: dict[str, str]) -> str:
|
|
178
|
+
"""Flag, then the process environment, then .env."""
|
|
179
|
+
if flag:
|
|
180
|
+
return flag
|
|
181
|
+
if key in environ:
|
|
182
|
+
return environ[key]
|
|
183
|
+
return from_file.get(key, "")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _home(environ: dict[str, str], cwd: Path, *, local: bool, root: Path) -> Path:
|
|
187
|
+
"""The directory the loop runs from. On Slurm it must be a source
|
|
188
|
+
checkout (OUTERLOOP_HOME, else the current directory): the chain
|
|
189
|
+
deploys from it and every job runs from a flight snapshot of its HEAD.
|
|
190
|
+
The local loop has no deploy step and runs the installed package, so it
|
|
191
|
+
uses a checkout when one is at hand and otherwise a `home` directory
|
|
192
|
+
under the state root, where flights and logs land."""
|
|
193
|
+
named = environ.get("OUTERLOOP_HOME") or environ.get("AUTORESEARCH_HOME")
|
|
194
|
+
home = Path(named).expanduser() if named else cwd
|
|
195
|
+
if (home / "scripts" / "tick_chain.sbatch").is_file():
|
|
196
|
+
return home
|
|
197
|
+
if local and not named:
|
|
198
|
+
return root / "home"
|
|
199
|
+
raise StartError(
|
|
200
|
+
f"{home} is not a source checkout (no scripts/tick_chain.sbatch); "
|
|
201
|
+
"run start from a clone of the kernel, or set OUTERLOOP_HOME to one"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def plan_start(
|
|
206
|
+
*,
|
|
207
|
+
root: str,
|
|
208
|
+
account: str,
|
|
209
|
+
partition: str,
|
|
210
|
+
local: bool,
|
|
211
|
+
environ: dict[str, str],
|
|
212
|
+
from_file: dict[str, str],
|
|
213
|
+
sbatch_on_path: bool,
|
|
214
|
+
cwd: Path,
|
|
215
|
+
) -> StartPlan:
|
|
216
|
+
compute = _setting("OUTERLOOP_COMPUTE", "local" if local else "", environ, from_file)
|
|
217
|
+
mode = "local" if compute.strip().lower() == "local" or not sbatch_on_path else "slurm"
|
|
218
|
+
root_s = _setting("OUTERLOOP_ROOT", root, environ, from_file)
|
|
219
|
+
cadence = _setting("OUTERLOOP_CADENCE_MIN", "", environ, from_file)
|
|
220
|
+
if cadence:
|
|
221
|
+
# the chain divides by it and the loop sleeps on it: a bad value would
|
|
222
|
+
# only surface after the job started
|
|
223
|
+
try:
|
|
224
|
+
cadence_ok = float(cadence) > 0
|
|
225
|
+
except ValueError:
|
|
226
|
+
cadence_ok = False
|
|
227
|
+
if not cadence_ok:
|
|
228
|
+
raise StartError(
|
|
229
|
+
f"OUTERLOOP_CADENCE_MIN must be a positive number of minutes, got {cadence!r}"
|
|
230
|
+
)
|
|
231
|
+
pat = _setting("OUTERLOOP_PAT_FILE", "", environ, from_file)
|
|
232
|
+
# Slurm runs from a checkout; the local loop runs the installed package
|
|
233
|
+
# and needs only a directory (the launch lanes and GitHub servicing
|
|
234
|
+
# switch off without OUTERLOOP_HOME, so one is always set)
|
|
235
|
+
local_root = Path(root_s).expanduser() if root_s else default_local_root(environ)
|
|
236
|
+
home = _home(environ, cwd, local=(mode == "local"), root=local_root)
|
|
237
|
+
if mode == "local":
|
|
238
|
+
return StartPlan(
|
|
239
|
+
mode="local",
|
|
240
|
+
root=local_root,
|
|
241
|
+
home=home,
|
|
242
|
+
cadence_min=cadence,
|
|
243
|
+
pat_file=pat,
|
|
244
|
+
)
|
|
245
|
+
if not root_s:
|
|
246
|
+
raise StartError(
|
|
247
|
+
"Slurm mode needs the state root on the shared filesystem: "
|
|
248
|
+
"--root, OUTERLOOP_ROOT in the environment, or OUTERLOOP_ROOT= in "
|
|
249
|
+
"~/.config/outerloop/.env"
|
|
250
|
+
)
|
|
251
|
+
acc = _setting("OUTERLOOP_ACCOUNT", account, environ, from_file)
|
|
252
|
+
part = _setting("OUTERLOOP_PARTITION", partition, environ, from_file)
|
|
253
|
+
# Account and partition are both optional: left unset, Slurm bills the
|
|
254
|
+
# caller's default association and places the job on its default partition.
|
|
255
|
+
minutes_s = _setting("OUTERLOOP_RESIDENT_MINUTES", "", environ, from_file)
|
|
256
|
+
try:
|
|
257
|
+
minutes = int(minutes_s) if minutes_s else DEFAULT_RESIDENT_MINUTES
|
|
258
|
+
except ValueError:
|
|
259
|
+
raise StartError(
|
|
260
|
+
f"OUTERLOOP_RESIDENT_MINUTES must be a whole number of minutes, got {minutes_s!r}"
|
|
261
|
+
) from None
|
|
262
|
+
if minutes <= 0:
|
|
263
|
+
raise StartError("OUTERLOOP_RESIDENT_MINUTES must be positive")
|
|
264
|
+
# These ride the inherited environment (sbatch --export=ALL), so a comma is
|
|
265
|
+
# safe now (a multi-partition `a,b` is valid) — only a newline would corrupt
|
|
266
|
+
# the environment or the sbatch argv.
|
|
267
|
+
for name, value in (
|
|
268
|
+
("root", root_s),
|
|
269
|
+
("account", acc),
|
|
270
|
+
("partition", part),
|
|
271
|
+
("cadence", cadence),
|
|
272
|
+
("PAT file", pat),
|
|
273
|
+
("checkout path", str(home)),
|
|
274
|
+
):
|
|
275
|
+
if "\n" in value or "\r" in value:
|
|
276
|
+
raise StartError(f"{name} {value!r} cannot contain a newline")
|
|
277
|
+
return StartPlan(
|
|
278
|
+
mode="slurm",
|
|
279
|
+
root=Path(root_s).expanduser(),
|
|
280
|
+
home=home,
|
|
281
|
+
account=acc,
|
|
282
|
+
partition=part,
|
|
283
|
+
cadence_min=cadence,
|
|
284
|
+
resident_minutes=minutes,
|
|
285
|
+
pat_file=pat,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def default_local_root(environ: Mapping[str, str]) -> Path:
|
|
290
|
+
"""The local-mode state root when none is given: ~/.outerloop, or the
|
|
291
|
+
pre-rename ~/.autoresearch when only that one exists (state is never moved
|
|
292
|
+
behind the operator's back). The home comes from `environ` so a caller
|
|
293
|
+
with no HOME gets the plain default."""
|
|
294
|
+
home_s = environ.get("HOME", "")
|
|
295
|
+
if not home_s:
|
|
296
|
+
return DEFAULT_LOCAL_ROOT
|
|
297
|
+
home = Path(home_s)
|
|
298
|
+
new, old = home / DEFAULT_LOCAL_ROOT.name, home / LEGACY_LOCAL_ROOT_NAME
|
|
299
|
+
return old if (not new.exists() and old.is_dir()) else new
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _resident_jobs() -> list[str] | None:
|
|
303
|
+
"""Ids of queued or running resident ticks under either name, lowest first;
|
|
304
|
+
None when the scheduler could not be asked (a failed lookup must never
|
|
305
|
+
read as 'none')."""
|
|
306
|
+
try:
|
|
307
|
+
proc = subprocess.run(
|
|
308
|
+
[
|
|
309
|
+
"squeue",
|
|
310
|
+
"-u",
|
|
311
|
+
os.environ.get("USER", ""),
|
|
312
|
+
f"--name={RESIDENT_JOB_NAME},{LEGACY_RESIDENT_JOB_NAME}",
|
|
313
|
+
"-h",
|
|
314
|
+
"-o",
|
|
315
|
+
"%i",
|
|
316
|
+
],
|
|
317
|
+
capture_output=True,
|
|
318
|
+
text=True,
|
|
319
|
+
timeout=30,
|
|
320
|
+
check=False,
|
|
321
|
+
)
|
|
322
|
+
except (OSError, subprocess.SubprocessError):
|
|
323
|
+
return None
|
|
324
|
+
if proc.returncode != 0:
|
|
325
|
+
return None
|
|
326
|
+
ids = [line.strip() for line in proc.stdout.splitlines() if line.strip()]
|
|
327
|
+
return sorted(ids, key=lambda s: (len(s), s))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _cancel(job: str) -> bool:
|
|
331
|
+
try:
|
|
332
|
+
proc = subprocess.run(
|
|
333
|
+
["scancel", job], capture_output=True, text=True, timeout=30, check=False
|
|
334
|
+
)
|
|
335
|
+
except (OSError, subprocess.SubprocessError):
|
|
336
|
+
return False
|
|
337
|
+
return proc.returncode == 0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _exec(cmd: list[str], env: dict[str, str]) -> int:
|
|
341
|
+
os.execvpe(cmd[0], cmd, env)
|
|
342
|
+
return 1 # unreachable; keeps the signature honest for tests that stub this
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# where the uv installer puts the binary before the shell's PATH knows it
|
|
346
|
+
UV_FALLBACK_DIRS = (".local/bin", ".cargo/bin")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def find_uv() -> tuple[str, str]:
|
|
350
|
+
"""(uv's path, the directory to prepend to PATH): the directory is "" when
|
|
351
|
+
uv is already on PATH, both are "" when it is nowhere. Every evaluation and
|
|
352
|
+
launch runs through `uv run` with the PATH start hands over, so a missing
|
|
353
|
+
uv is caught here, not in a run that ends unmeasured."""
|
|
354
|
+
found = shutil.which("uv")
|
|
355
|
+
if found:
|
|
356
|
+
return found, ""
|
|
357
|
+
for rel in UV_FALLBACK_DIRS:
|
|
358
|
+
candidate = Path.home() / rel / "uv"
|
|
359
|
+
# a regular executable file, as `which` would accept: a directory of
|
|
360
|
+
# that name is searchable, not runnable
|
|
361
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
362
|
+
return str(candidate), str(candidate.parent)
|
|
363
|
+
return "", ""
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
HARNESS_BIN_KEYS = ("OUTERLOOP_CLAUDE_BIN", "OUTERLOOP_CODEX_BIN")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _setting_of(key: str, values: Mapping[str, str], environ: Mapping[str, str]) -> str:
|
|
370
|
+
"""The process environment wins over .env, including an explicit empty value
|
|
371
|
+
(the way to clear a recorded path from the shell)."""
|
|
372
|
+
return (environ[key] if key in environ else values.get(key, "")).strip()
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def missing_harness_binary(values: Mapping[str, str], environ: Mapping[str, str]) -> str:
|
|
376
|
+
"""Why start must not launch: the CONFIGURED author backend's recorded
|
|
377
|
+
binary is not there (init records the path; a moved or uninstalled CLI
|
|
378
|
+
would end every climb with spawn-error). The other backend's path is not
|
|
379
|
+
consulted: a stale entry for a backend the loop never spawns must not block
|
|
380
|
+
it. "" when the binary exists or none is recorded."""
|
|
381
|
+
backend = _setting_of("OUTERLOOP_AUTHOR_BACKEND", values, environ).lower() or "claude"
|
|
382
|
+
key = f"OUTERLOOP_{backend.upper()}_BIN"
|
|
383
|
+
if key not in HARNESS_BIN_KEYS:
|
|
384
|
+
return "" # an unknown backend is init's error to report, not this check's
|
|
385
|
+
path = _setting_of(key, values, environ)
|
|
386
|
+
if path and not Path(path).expanduser().is_file():
|
|
387
|
+
return f"{key}={path} is not a file; reinstall the CLI or run `outerloop init --force`"
|
|
388
|
+
return ""
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def start(args: argparse.Namespace) -> int:
|
|
392
|
+
try:
|
|
393
|
+
values = env_file_values(ENV_FILE, START_KEYS + TICK_ENV_KEYS) # one read for everything
|
|
394
|
+
problem = missing_harness_binary(values, os.environ)
|
|
395
|
+
if problem:
|
|
396
|
+
raise StartError(problem)
|
|
397
|
+
from_file = {k: v for k, v in values.items() if k in START_KEYS}
|
|
398
|
+
plan = plan_start(
|
|
399
|
+
root=args.root or "",
|
|
400
|
+
account=args.account or "",
|
|
401
|
+
partition=args.partition or "",
|
|
402
|
+
local=args.local,
|
|
403
|
+
environ=dict(os.environ),
|
|
404
|
+
from_file=from_file,
|
|
405
|
+
sbatch_on_path=shutil.which("sbatch") is not None,
|
|
406
|
+
cwd=Path.cwd(),
|
|
407
|
+
)
|
|
408
|
+
except StartError as e:
|
|
409
|
+
print(f"outerloop start: {e}", file=sys.stderr)
|
|
410
|
+
return 2
|
|
411
|
+
cmd = plan.command()
|
|
412
|
+
if args.dry_run:
|
|
413
|
+
print(shlex.join(cmd))
|
|
414
|
+
return 0
|
|
415
|
+
uv, uv_dir = find_uv()
|
|
416
|
+
if not uv:
|
|
417
|
+
print(
|
|
418
|
+
"outerloop start: uv is not on PATH. Evaluations and launches run through "
|
|
419
|
+
"`uv run`; install it (https://docs.astral.sh/uv/) or add its directory to "
|
|
420
|
+
"PATH, then start again.",
|
|
421
|
+
file=sys.stderr,
|
|
422
|
+
)
|
|
423
|
+
return 2
|
|
424
|
+
path_env = {"PATH": uv_dir + os.pathsep + os.environ.get("PATH", "")} if uv_dir else {}
|
|
425
|
+
if uv_dir:
|
|
426
|
+
print(f"uv found at {uv}; {uv_dir} is added to the loop's PATH", file=sys.stderr)
|
|
427
|
+
if plan.mode == "local":
|
|
428
|
+
# the loop has no deploy step, so the author knobs the chain would
|
|
429
|
+
# export from .env each tick are exported here once; the shell wins
|
|
430
|
+
env = {**os.environ, **path_env}
|
|
431
|
+
for key, value in values.items():
|
|
432
|
+
if key in TICK_ENV_KEYS:
|
|
433
|
+
env.setdefault(key, value)
|
|
434
|
+
env["OUTERLOOP_COMPUTE"] = "local"
|
|
435
|
+
env["OUTERLOOP_ROOT"] = str(plan.root)
|
|
436
|
+
env["OUTERLOOP_HOME"] = str(plan.home)
|
|
437
|
+
plan.home.mkdir(parents=True, exist_ok=True) # <root>/home when there is no checkout
|
|
438
|
+
if plan.cadence_min:
|
|
439
|
+
env["OUTERLOOP_CADENCE_MIN"] = plan.cadence_min
|
|
440
|
+
if plan.pat_file:
|
|
441
|
+
env["OUTERLOOP_PAT_FILE"] = plan.pat_file
|
|
442
|
+
print(
|
|
443
|
+
f"local loop: state in {plan.root}; Ctrl-C stops it, the records resume it",
|
|
444
|
+
file=sys.stderr,
|
|
445
|
+
)
|
|
446
|
+
return _exec(cmd, env)
|
|
447
|
+
existing = _resident_jobs()
|
|
448
|
+
if existing is None:
|
|
449
|
+
print(
|
|
450
|
+
"outerloop start: could not ask the scheduler whether a resident tick "
|
|
451
|
+
"exists (squeue failed); nothing submitted. Retry, or check "
|
|
452
|
+
f"`squeue --name {RESIDENT_JOB_NAME},{LEGACY_RESIDENT_JOB_NAME}`.",
|
|
453
|
+
file=sys.stderr,
|
|
454
|
+
)
|
|
455
|
+
return 1
|
|
456
|
+
if existing:
|
|
457
|
+
print(
|
|
458
|
+
f"a resident tick is already queued or running (job {existing[0]}); nothing "
|
|
459
|
+
f"submitted. Stop it with `scancel {existing[0]}`, or pause it "
|
|
460
|
+
f"with `touch {plan.root}/PAUSE`.",
|
|
461
|
+
file=sys.stderr,
|
|
462
|
+
)
|
|
463
|
+
return 0
|
|
464
|
+
# sbatch --export=ALL carries these to the resident job from the environment
|
|
465
|
+
# we hand it here (so a comma in a value never breaks a --export delimiter).
|
|
466
|
+
submit_env = {**os.environ, **path_env, **plan.export_env()}
|
|
467
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, env=submit_env, check=False)
|
|
468
|
+
if proc.returncode != 0:
|
|
469
|
+
print(
|
|
470
|
+
f"outerloop start: sbatch failed: {(proc.stderr or proc.stdout).strip()}",
|
|
471
|
+
file=sys.stderr,
|
|
472
|
+
)
|
|
473
|
+
return 1
|
|
474
|
+
job = proc.stdout.strip().split(";")[0]
|
|
475
|
+
# two starts can pass the check above together; singleton keeps them from
|
|
476
|
+
# running at once, and the later submission withdraws so one chain remains
|
|
477
|
+
after = _resident_jobs()
|
|
478
|
+
if after and after[0] != job and job in after:
|
|
479
|
+
if _cancel(job):
|
|
480
|
+
print(
|
|
481
|
+
f"another resident tick (job {after[0]}) was submitted at the same time; "
|
|
482
|
+
f"withdrew this one (job {job}).",
|
|
483
|
+
file=sys.stderr,
|
|
484
|
+
)
|
|
485
|
+
return 0
|
|
486
|
+
# a queued loser would run after the winner and start a second chain
|
|
487
|
+
print(
|
|
488
|
+
f"another resident tick (job {after[0]}) was submitted at the same time and "
|
|
489
|
+
f"this one (job {job}) could not be cancelled; cancel it by hand: scancel {job}",
|
|
490
|
+
file=sys.stderr,
|
|
491
|
+
)
|
|
492
|
+
return 1
|
|
493
|
+
print(
|
|
494
|
+
f"resident tick submitted: job {job} on {plan.partition}, "
|
|
495
|
+
f"{plan.resident_minutes} min walltime, hands over to itself. "
|
|
496
|
+
f"Logs: {plan.root}/logs. Pause: touch {plan.root}/PAUSE. "
|
|
497
|
+
f"Stop: scancel --name {RESIDENT_JOB_NAME}."
|
|
498
|
+
)
|
|
499
|
+
return 0
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
DIST = "outerloop-science" # the PyPI distribution; imported as `outerloop`
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _installed_version(python: str) -> str:
|
|
506
|
+
"""The installed version of the distribution, read from a fresh interpreter
|
|
507
|
+
so it reflects what pip just wrote rather than this process's imported copy."""
|
|
508
|
+
proc = subprocess.run(
|
|
509
|
+
[python, "-c", f"import importlib.metadata as m; print(m.version({DIST!r}))"],
|
|
510
|
+
capture_output=True,
|
|
511
|
+
text=True,
|
|
512
|
+
check=False,
|
|
513
|
+
)
|
|
514
|
+
return proc.stdout.strip() or "unknown"
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def upgrade(args: argparse.Namespace) -> int:
|
|
518
|
+
"""Upgrade the installed package in place, the local adopter's one verb.
|
|
519
|
+
|
|
520
|
+
On Slurm the resident tick self-updates through OUTERLOOP_AUTO_UPDATE; a local
|
|
521
|
+
install has no such loop, so this is the equivalent: `pip install --upgrade`,
|
|
522
|
+
then you restart the loop to pick the new code up. Pip already picks the newest
|
|
523
|
+
release and only falls back to a pre-release when that is all that is published;
|
|
524
|
+
--pre forces pre-releases even once a stable exists."""
|
|
525
|
+
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", DIST]
|
|
526
|
+
if args.pre:
|
|
527
|
+
cmd.append("--pre")
|
|
528
|
+
if args.dry_run:
|
|
529
|
+
print(shlex.join(cmd))
|
|
530
|
+
return 0
|
|
531
|
+
before = _installed_version(sys.executable)
|
|
532
|
+
proc = subprocess.run(cmd, check=False)
|
|
533
|
+
if proc.returncode != 0:
|
|
534
|
+
print(
|
|
535
|
+
f"upgrade failed (pip exited {proc.returncode}). If this environment has no "
|
|
536
|
+
f"pip, upgrade through its installer instead, e.g. `uv pip install --upgrade {DIST}`.",
|
|
537
|
+
file=sys.stderr,
|
|
538
|
+
)
|
|
539
|
+
return proc.returncode
|
|
540
|
+
after = _installed_version(sys.executable)
|
|
541
|
+
if before == after:
|
|
542
|
+
print(f"already up to date: outerloop {after}.")
|
|
543
|
+
else:
|
|
544
|
+
print(
|
|
545
|
+
f"upgraded outerloop {before} -> {after}. Restart the loop to pick it up: "
|
|
546
|
+
f"stop the running tick, then `outerloop start`."
|
|
547
|
+
)
|
|
548
|
+
return 0
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def main(argv: list[str] | None = None) -> int:
|
|
552
|
+
from outerloop import __version__
|
|
553
|
+
|
|
554
|
+
parser = argparse.ArgumentParser(
|
|
555
|
+
prog="outerloop",
|
|
556
|
+
description="autonomous research agents in an outer loop",
|
|
557
|
+
epilog="Run `outerloop init` once, then `outerloop start`. Settings live in "
|
|
558
|
+
"~/.config/outerloop/.env; the install guide is docs/install.md.",
|
|
559
|
+
)
|
|
560
|
+
parser.add_argument("--version", action="version", version=f"outerloop {__version__}")
|
|
561
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
562
|
+
p = sub.add_parser(
|
|
563
|
+
"start",
|
|
564
|
+
help="start the loop: the resident tick on Slurm, the local loop elsewhere",
|
|
565
|
+
description="Submit the resident tick to Slurm, or run the local loop in the "
|
|
566
|
+
"foreground where there is no sbatch. Every flag defaults from "
|
|
567
|
+
"~/.config/outerloop/.env, written by `outerloop init`.",
|
|
568
|
+
)
|
|
569
|
+
p.add_argument(
|
|
570
|
+
"--root", help="state root (shared filesystem on Slurm; default ~/.outerloop locally)"
|
|
571
|
+
)
|
|
572
|
+
p.add_argument(
|
|
573
|
+
"--account", help="Slurm account (optional; unset bills your default association)"
|
|
574
|
+
)
|
|
575
|
+
p.add_argument(
|
|
576
|
+
"--partition",
|
|
577
|
+
help="Slurm partition for the tick (optional; unset lets Slurm choose; a,b = list)",
|
|
578
|
+
)
|
|
579
|
+
p.add_argument(
|
|
580
|
+
"--local", action="store_true", help="run the local loop even where sbatch exists"
|
|
581
|
+
)
|
|
582
|
+
p.add_argument("--dry-run", action="store_true", help="print the command and exit")
|
|
583
|
+
sub.add_parser(
|
|
584
|
+
"tick", help="run one tick now; --loop keeps ticking (the local loop)", add_help=False
|
|
585
|
+
)
|
|
586
|
+
sub.add_parser(
|
|
587
|
+
"init",
|
|
588
|
+
help="guided setup: write ~/.config/outerloop/.env and the PAT file",
|
|
589
|
+
add_help=False,
|
|
590
|
+
)
|
|
591
|
+
up = sub.add_parser(
|
|
592
|
+
"upgrade",
|
|
593
|
+
help="upgrade the installed package, then restart the loop to pick it up",
|
|
594
|
+
description="Upgrade outerloop-science in this environment with pip. On Slurm the "
|
|
595
|
+
"resident tick self-updates through OUTERLOOP_AUTO_UPDATE; this is the equivalent "
|
|
596
|
+
"for a local install. Restart the loop afterwards to run the new code.",
|
|
597
|
+
)
|
|
598
|
+
up.add_argument(
|
|
599
|
+
"--pre", action="store_true", help="include pre-releases even once a stable exists"
|
|
600
|
+
)
|
|
601
|
+
up.add_argument("--dry-run", action="store_true", help="print the command and exit")
|
|
602
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
603
|
+
if argv[:1] == ["tick"]:
|
|
604
|
+
# the tick entry owns its own parser; hand it the rest untouched
|
|
605
|
+
from outerloop import tick
|
|
606
|
+
|
|
607
|
+
sys.argv = ["outerloop tick", *argv[1:]]
|
|
608
|
+
return tick.main()
|
|
609
|
+
if argv[:1] == ["init"]:
|
|
610
|
+
# init owns its own parser too; hand it the args after "init"
|
|
611
|
+
from outerloop import init
|
|
612
|
+
|
|
613
|
+
return init.main(argv[1:])
|
|
614
|
+
args = parser.parse_args(argv)
|
|
615
|
+
if args.command == "upgrade":
|
|
616
|
+
return upgrade(args)
|
|
617
|
+
return start(args)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
if __name__ == "__main__":
|
|
621
|
+
sys.exit(main())
|