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
outerloop/cli.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
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
|
+
AUTORESEARCH_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 dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from outerloop import paths
|
|
25
|
+
|
|
26
|
+
RESIDENT_JOB_NAME = "autoresearch-resident"
|
|
27
|
+
DEFAULT_RESIDENT_MINUTES = 360 # cpu_short's ceiling on Torch; the loop hands over to itself
|
|
28
|
+
DEFAULT_LOCAL_ROOT = Path.home() / ".autoresearch"
|
|
29
|
+
ENV_FILE = paths.ENV_FILE # ~/.config/outerloop/.env, or the pre-rename dir (see paths.py)
|
|
30
|
+
|
|
31
|
+
# What start itself decides from: mode, placement, root, cadence, walltime.
|
|
32
|
+
START_KEYS = (
|
|
33
|
+
"AUTORESEARCH_COMPUTE",
|
|
34
|
+
"AUTORESEARCH_ROOT",
|
|
35
|
+
"AUTORESEARCH_ACCOUNT",
|
|
36
|
+
"AUTORESEARCH_PARTITION",
|
|
37
|
+
"AUTORESEARCH_CADENCE_MIN",
|
|
38
|
+
"AUTORESEARCH_RESIDENT_MINUTES",
|
|
39
|
+
"AUTORESEARCH_PAT_FILE",
|
|
40
|
+
)
|
|
41
|
+
# The author knobs the chain's deploy step exports from .env every tick. The
|
|
42
|
+
# local loop has no deploy step, so start exports them once at launch; a test
|
|
43
|
+
# keeps this list identical to tick_deploy.sh's.
|
|
44
|
+
TICK_ENV_KEYS = (
|
|
45
|
+
"AUTORESEARCH_AUTHOR_BACKEND",
|
|
46
|
+
"AUTORESEARCH_AUTHOR_MODEL",
|
|
47
|
+
"AUTORESEARCH_CODEX_BIN",
|
|
48
|
+
"AUTORESEARCH_CODEX_KEY_FILE",
|
|
49
|
+
"AUTORESEARCH_HARNESS_KEY_FILE",
|
|
50
|
+
"AUTORESEARCH_VERTEX_PROJECT",
|
|
51
|
+
"AUTORESEARCH_VERTEX_REGION",
|
|
52
|
+
"AUTORESEARCH_VERTEX_ADC",
|
|
53
|
+
"AUTORESEARCH_TARGET",
|
|
54
|
+
"AUTORESEARCH_GITHUB_APP_FILE",
|
|
55
|
+
"AUTORESEARCH_BOT_LOGIN",
|
|
56
|
+
"AUTORESEARCH_BOT_ALIASES",
|
|
57
|
+
"AUTORESEARCH_GPU_PARTITION",
|
|
58
|
+
"AUTORESEARCH_GPU_ACCOUNT",
|
|
59
|
+
"AUTORESEARCH_PANEL",
|
|
60
|
+
"AUTORESEARCH_PANEL_KEY_FILE",
|
|
61
|
+
"AUTORESEARCH_PANEL_CODEX_KEY_FILE",
|
|
62
|
+
"AUTORESEARCH_PANEL_HERMES_KEY_FILE",
|
|
63
|
+
"REVIEW_HERMES_REPO",
|
|
64
|
+
"REVIEW_HERMES_PROVIDER",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class StartError(Exception):
|
|
69
|
+
"""A start that cannot proceed; the message is the whole diagnosis."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def env_file_values(path: Path = ENV_FILE, keys: tuple[str, ...] = START_KEYS) -> dict[str, str]:
|
|
73
|
+
"""`keys` from the operator's .env under the deploy step's trust rule: the
|
|
74
|
+
file must be ours and not group/world-writable, or it is refused. Last
|
|
75
|
+
assignment wins; surrounding quotes and a CR are stripped; a key set to
|
|
76
|
+
an empty value is present (an off-switch), an absent key is absent.
|
|
77
|
+
No file: nothing."""
|
|
78
|
+
try:
|
|
79
|
+
st = path.stat()
|
|
80
|
+
except OSError:
|
|
81
|
+
return {}
|
|
82
|
+
if st.st_uid != os.getuid() or st.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
|
|
83
|
+
raise StartError(
|
|
84
|
+
f"refusing to read {path}: it must be owned by you and not group/world-writable"
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
text = path.read_text()
|
|
88
|
+
except OSError as e:
|
|
89
|
+
raise StartError(f"cannot read {path}: {e}") from None
|
|
90
|
+
out: dict[str, str] = {}
|
|
91
|
+
for raw in text.splitlines():
|
|
92
|
+
line = raw.rstrip("\r").strip()
|
|
93
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
94
|
+
continue
|
|
95
|
+
key, value = line.split("=", 1)
|
|
96
|
+
key = key.strip()
|
|
97
|
+
if key.startswith("OUTERLOOP_"): # the public name; `keys` are canonical
|
|
98
|
+
key = "AUTORESEARCH_" + key[len("OUTERLOOP_") :]
|
|
99
|
+
if key not in keys:
|
|
100
|
+
continue
|
|
101
|
+
value = value.strip()
|
|
102
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
103
|
+
value = value[1:-1]
|
|
104
|
+
out[key] = value
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class StartPlan:
|
|
110
|
+
mode: str # "slurm" | "local"
|
|
111
|
+
root: Path
|
|
112
|
+
home: Path = Path(".")
|
|
113
|
+
account: str = ""
|
|
114
|
+
partition: str = ""
|
|
115
|
+
cadence_min: str = ""
|
|
116
|
+
resident_minutes: int = DEFAULT_RESIDENT_MINUTES
|
|
117
|
+
pat_file: str = ""
|
|
118
|
+
|
|
119
|
+
def export_env(self) -> dict[str, str]:
|
|
120
|
+
"""The knobs the resident job needs. They ride the inherited environment
|
|
121
|
+
(sbatch `--export=ALL`), NOT a comma-joined `--export=K=V,K=V` list — so a
|
|
122
|
+
value may itself contain a comma (e.g. a multi-partition `a,b`, which Slurm
|
|
123
|
+
reads as "whichever frees up first") without corrupting the export
|
|
124
|
+
delimiter. start() merges these into the environment it hands sbatch."""
|
|
125
|
+
env = {
|
|
126
|
+
"AUTORESEARCH_RESIDENT": "1",
|
|
127
|
+
"AUTORESEARCH_HOME": str(self.home),
|
|
128
|
+
"AUTORESEARCH_ROOT": str(self.root),
|
|
129
|
+
"AUTORESEARCH_ACCOUNT": self.account,
|
|
130
|
+
"AUTORESEARCH_RESIDENT_MINUTES": str(self.resident_minutes), # successors reuse it
|
|
131
|
+
}
|
|
132
|
+
if self.partition:
|
|
133
|
+
env["AUTORESEARCH_PARTITION"] = self.partition
|
|
134
|
+
if self.cadence_min:
|
|
135
|
+
env["AUTORESEARCH_CADENCE_MIN"] = self.cadence_min
|
|
136
|
+
if self.pat_file:
|
|
137
|
+
env["AUTORESEARCH_PAT_FILE"] = self.pat_file
|
|
138
|
+
return env
|
|
139
|
+
|
|
140
|
+
def command(self) -> list[str]:
|
|
141
|
+
if self.mode == "local":
|
|
142
|
+
return [sys.executable, "-m", "outerloop.tick", "--root", str(self.root), "--loop"]
|
|
143
|
+
argv = [
|
|
144
|
+
"sbatch",
|
|
145
|
+
"--parsable",
|
|
146
|
+
"--dependency=singleton", # two starts can both submit; only one ever runs
|
|
147
|
+
f"--time={self.resident_minutes}",
|
|
148
|
+
f"--job-name={RESIDENT_JOB_NAME}",
|
|
149
|
+
f"--account={self.account}",
|
|
150
|
+
]
|
|
151
|
+
if self.partition: # unset lets Slurm choose its default partition
|
|
152
|
+
argv.append(f"--partition={self.partition}")
|
|
153
|
+
# --export=ALL carries export_env() from the inherited environment; a
|
|
154
|
+
# comma-joined K=V list here would break on any value containing a comma.
|
|
155
|
+
argv += ["--export=ALL", str(self.home / "scripts" / "tick_chain.sbatch")]
|
|
156
|
+
return argv
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _setting(key: str, flag: str, environ: dict[str, str], from_file: dict[str, str]) -> str:
|
|
160
|
+
"""Flag, then the process environment, then .env."""
|
|
161
|
+
if flag:
|
|
162
|
+
return flag
|
|
163
|
+
if key in environ:
|
|
164
|
+
return environ[key]
|
|
165
|
+
return from_file.get(key, "")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _home(environ: dict[str, str], cwd: Path) -> Path:
|
|
169
|
+
"""The checkout the loop runs from: AUTORESEARCH_HOME, else the current
|
|
170
|
+
directory when it is one. Both modes need it; the tick's launch lanes
|
|
171
|
+
and GitHub servicing switch off without it."""
|
|
172
|
+
home = (
|
|
173
|
+
Path(environ["AUTORESEARCH_HOME"]).expanduser() if environ.get("AUTORESEARCH_HOME") else cwd
|
|
174
|
+
)
|
|
175
|
+
if not (home / "scripts" / "tick_chain.sbatch").is_file():
|
|
176
|
+
raise StartError(
|
|
177
|
+
f"{home} is not an autoresearch checkout (no scripts/tick_chain.sbatch); "
|
|
178
|
+
"run start from the checkout the chain should deploy from, or set AUTORESEARCH_HOME"
|
|
179
|
+
)
|
|
180
|
+
return home
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def plan_start(
|
|
184
|
+
*,
|
|
185
|
+
root: str,
|
|
186
|
+
account: str,
|
|
187
|
+
partition: str,
|
|
188
|
+
local: bool,
|
|
189
|
+
environ: dict[str, str],
|
|
190
|
+
from_file: dict[str, str],
|
|
191
|
+
sbatch_on_path: bool,
|
|
192
|
+
cwd: Path,
|
|
193
|
+
) -> StartPlan:
|
|
194
|
+
compute = _setting("AUTORESEARCH_COMPUTE", "local" if local else "", environ, from_file)
|
|
195
|
+
mode = "local" if compute.strip().lower() == "local" or not sbatch_on_path else "slurm"
|
|
196
|
+
root_s = _setting("AUTORESEARCH_ROOT", root, environ, from_file)
|
|
197
|
+
cadence = _setting("AUTORESEARCH_CADENCE_MIN", "", environ, from_file)
|
|
198
|
+
if cadence:
|
|
199
|
+
# the chain divides by it and the loop sleeps on it: a bad value would
|
|
200
|
+
# only surface after the job started
|
|
201
|
+
try:
|
|
202
|
+
cadence_ok = float(cadence) > 0
|
|
203
|
+
except ValueError:
|
|
204
|
+
cadence_ok = False
|
|
205
|
+
if not cadence_ok:
|
|
206
|
+
raise StartError(
|
|
207
|
+
f"AUTORESEARCH_CADENCE_MIN must be a positive number of minutes, got {cadence!r}"
|
|
208
|
+
)
|
|
209
|
+
pat = _setting("AUTORESEARCH_PAT_FILE", "", environ, from_file)
|
|
210
|
+
# both modes run from a checkout: the tick's launch lanes and GitHub
|
|
211
|
+
# servicing switch off without AUTORESEARCH_HOME
|
|
212
|
+
home = _home(environ, cwd)
|
|
213
|
+
if mode == "local":
|
|
214
|
+
return StartPlan(
|
|
215
|
+
mode="local",
|
|
216
|
+
root=Path(root_s).expanduser() if root_s else DEFAULT_LOCAL_ROOT,
|
|
217
|
+
home=home,
|
|
218
|
+
cadence_min=cadence,
|
|
219
|
+
pat_file=pat,
|
|
220
|
+
)
|
|
221
|
+
if not root_s:
|
|
222
|
+
raise StartError(
|
|
223
|
+
"Slurm mode needs the state root on the shared filesystem: "
|
|
224
|
+
"--root, AUTORESEARCH_ROOT, or AUTORESEARCH_ROOT= in ~/.config/outerloop/.env"
|
|
225
|
+
)
|
|
226
|
+
acc = _setting("AUTORESEARCH_ACCOUNT", account, environ, from_file)
|
|
227
|
+
part = _setting("AUTORESEARCH_PARTITION", partition, environ, from_file)
|
|
228
|
+
# Partition is optional: left unset, Slurm places the job on its default
|
|
229
|
+
# partition. Account stays required (clusters bill by it).
|
|
230
|
+
if not acc:
|
|
231
|
+
raise StartError("Slurm mode needs --account / AUTORESEARCH_ACCOUNT")
|
|
232
|
+
minutes_s = _setting("AUTORESEARCH_RESIDENT_MINUTES", "", environ, from_file)
|
|
233
|
+
try:
|
|
234
|
+
minutes = int(minutes_s) if minutes_s else DEFAULT_RESIDENT_MINUTES
|
|
235
|
+
except ValueError:
|
|
236
|
+
raise StartError(
|
|
237
|
+
f"AUTORESEARCH_RESIDENT_MINUTES must be a whole number of minutes, got {minutes_s!r}"
|
|
238
|
+
) from None
|
|
239
|
+
if minutes <= 0:
|
|
240
|
+
raise StartError("AUTORESEARCH_RESIDENT_MINUTES must be positive")
|
|
241
|
+
# These ride the inherited environment (sbatch --export=ALL), so a comma is
|
|
242
|
+
# safe now (a multi-partition `a,b` is valid) — only a newline would corrupt
|
|
243
|
+
# the environment or the sbatch argv.
|
|
244
|
+
for name, value in (
|
|
245
|
+
("root", root_s),
|
|
246
|
+
("account", acc),
|
|
247
|
+
("partition", part),
|
|
248
|
+
("cadence", cadence),
|
|
249
|
+
("PAT file", pat),
|
|
250
|
+
("checkout path", str(home)),
|
|
251
|
+
):
|
|
252
|
+
if "\n" in value or "\r" in value:
|
|
253
|
+
raise StartError(f"{name} {value!r} cannot contain a newline")
|
|
254
|
+
return StartPlan(
|
|
255
|
+
mode="slurm",
|
|
256
|
+
root=Path(root_s).expanduser(),
|
|
257
|
+
home=home,
|
|
258
|
+
account=acc,
|
|
259
|
+
partition=part,
|
|
260
|
+
cadence_min=cadence,
|
|
261
|
+
resident_minutes=minutes,
|
|
262
|
+
pat_file=pat,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _resident_jobs() -> list[str] | None:
|
|
267
|
+
"""Ids of queued or running resident ticks, lowest first; None when the
|
|
268
|
+
scheduler could not be asked (a failed lookup must never read as 'none')."""
|
|
269
|
+
try:
|
|
270
|
+
proc = subprocess.run(
|
|
271
|
+
[
|
|
272
|
+
"squeue",
|
|
273
|
+
"-u",
|
|
274
|
+
os.environ.get("USER", ""),
|
|
275
|
+
f"--name={RESIDENT_JOB_NAME}",
|
|
276
|
+
"-h",
|
|
277
|
+
"-o",
|
|
278
|
+
"%i",
|
|
279
|
+
],
|
|
280
|
+
capture_output=True,
|
|
281
|
+
text=True,
|
|
282
|
+
timeout=30,
|
|
283
|
+
)
|
|
284
|
+
except (OSError, subprocess.SubprocessError):
|
|
285
|
+
return None
|
|
286
|
+
if proc.returncode != 0:
|
|
287
|
+
return None
|
|
288
|
+
ids = [line.strip() for line in proc.stdout.splitlines() if line.strip()]
|
|
289
|
+
return sorted(ids, key=lambda s: (len(s), s))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _cancel(job: str) -> bool:
|
|
293
|
+
try:
|
|
294
|
+
proc = subprocess.run(["scancel", job], capture_output=True, text=True, timeout=30)
|
|
295
|
+
except (OSError, subprocess.SubprocessError):
|
|
296
|
+
return False
|
|
297
|
+
return proc.returncode == 0
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _exec(cmd: list[str], env: dict[str, str]) -> int:
|
|
301
|
+
os.execvpe(cmd[0], cmd, env)
|
|
302
|
+
return 1 # unreachable; keeps the signature honest for tests that stub this
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def start(args: argparse.Namespace) -> int:
|
|
306
|
+
try:
|
|
307
|
+
values = env_file_values(ENV_FILE, START_KEYS + TICK_ENV_KEYS) # one read for everything
|
|
308
|
+
from_file = {k: v for k, v in values.items() if k in START_KEYS}
|
|
309
|
+
plan = plan_start(
|
|
310
|
+
root=args.root or "",
|
|
311
|
+
account=args.account or "",
|
|
312
|
+
partition=args.partition or "",
|
|
313
|
+
local=args.local,
|
|
314
|
+
environ=dict(os.environ),
|
|
315
|
+
from_file=from_file,
|
|
316
|
+
sbatch_on_path=shutil.which("sbatch") is not None,
|
|
317
|
+
cwd=Path.cwd(),
|
|
318
|
+
)
|
|
319
|
+
except StartError as e:
|
|
320
|
+
print(f"outerloop start: {e}", file=sys.stderr)
|
|
321
|
+
return 2
|
|
322
|
+
cmd = plan.command()
|
|
323
|
+
if args.dry_run:
|
|
324
|
+
print(shlex.join(cmd))
|
|
325
|
+
return 0
|
|
326
|
+
if plan.mode == "local":
|
|
327
|
+
# the loop has no deploy step, so the author knobs the chain would
|
|
328
|
+
# export from .env each tick are exported here once; the shell wins
|
|
329
|
+
env = dict(os.environ)
|
|
330
|
+
for key, value in values.items():
|
|
331
|
+
if key in TICK_ENV_KEYS:
|
|
332
|
+
env.setdefault(key, value)
|
|
333
|
+
env["AUTORESEARCH_COMPUTE"] = "local"
|
|
334
|
+
env["AUTORESEARCH_ROOT"] = str(plan.root)
|
|
335
|
+
env["AUTORESEARCH_HOME"] = str(plan.home)
|
|
336
|
+
if plan.cadence_min:
|
|
337
|
+
env["AUTORESEARCH_CADENCE_MIN"] = plan.cadence_min
|
|
338
|
+
if plan.pat_file:
|
|
339
|
+
env["AUTORESEARCH_PAT_FILE"] = plan.pat_file
|
|
340
|
+
print(
|
|
341
|
+
f"local loop: state in {plan.root}; Ctrl-C stops it, the records resume it",
|
|
342
|
+
file=sys.stderr,
|
|
343
|
+
)
|
|
344
|
+
return _exec(cmd, env)
|
|
345
|
+
existing = _resident_jobs()
|
|
346
|
+
if existing is None:
|
|
347
|
+
print(
|
|
348
|
+
"outerloop start: could not ask the scheduler whether a resident tick "
|
|
349
|
+
"exists (squeue failed); nothing submitted. Retry, or check "
|
|
350
|
+
f"`squeue --name {RESIDENT_JOB_NAME}`.",
|
|
351
|
+
file=sys.stderr,
|
|
352
|
+
)
|
|
353
|
+
return 1
|
|
354
|
+
if existing:
|
|
355
|
+
print(
|
|
356
|
+
f"a resident tick is already queued or running (job {existing[0]}); nothing "
|
|
357
|
+
f"submitted. Stop it with `scancel --name {RESIDENT_JOB_NAME}`, or pause it "
|
|
358
|
+
f"with `touch {plan.root}/PAUSE`.",
|
|
359
|
+
file=sys.stderr,
|
|
360
|
+
)
|
|
361
|
+
return 0
|
|
362
|
+
# sbatch --export=ALL carries these to the resident job from the environment
|
|
363
|
+
# we hand it here (so a comma in a value never breaks a --export delimiter).
|
|
364
|
+
submit_env = {**os.environ, **plan.export_env()}
|
|
365
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, env=submit_env)
|
|
366
|
+
if proc.returncode != 0:
|
|
367
|
+
print(
|
|
368
|
+
f"outerloop start: sbatch failed: {(proc.stderr or proc.stdout).strip()}",
|
|
369
|
+
file=sys.stderr,
|
|
370
|
+
)
|
|
371
|
+
return 1
|
|
372
|
+
job = proc.stdout.strip().split(";")[0]
|
|
373
|
+
# two starts can pass the check above together; singleton keeps them from
|
|
374
|
+
# running at once, and the later submission withdraws so one chain remains
|
|
375
|
+
after = _resident_jobs()
|
|
376
|
+
if after and after[0] != job and job in after:
|
|
377
|
+
if _cancel(job):
|
|
378
|
+
print(
|
|
379
|
+
f"another resident tick (job {after[0]}) was submitted at the same time; "
|
|
380
|
+
f"withdrew this one (job {job}).",
|
|
381
|
+
file=sys.stderr,
|
|
382
|
+
)
|
|
383
|
+
return 0
|
|
384
|
+
# a queued loser would run after the winner and start a second chain
|
|
385
|
+
print(
|
|
386
|
+
f"another resident tick (job {after[0]}) was submitted at the same time and "
|
|
387
|
+
f"this one (job {job}) could not be cancelled; cancel it by hand: scancel {job}",
|
|
388
|
+
file=sys.stderr,
|
|
389
|
+
)
|
|
390
|
+
return 1
|
|
391
|
+
print(
|
|
392
|
+
f"resident tick submitted: job {job} on {plan.partition}, "
|
|
393
|
+
f"{plan.resident_minutes} min walltime, hands over to itself. "
|
|
394
|
+
f"Logs: {plan.root}/logs. Pause: touch {plan.root}/PAUSE. "
|
|
395
|
+
f"Stop: scancel --name {RESIDENT_JOB_NAME}."
|
|
396
|
+
)
|
|
397
|
+
return 0
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def main(argv: list[str] | None = None) -> int:
|
|
401
|
+
parser = argparse.ArgumentParser(
|
|
402
|
+
prog="outerloop", description="autonomous research agents in an outer loop"
|
|
403
|
+
)
|
|
404
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
405
|
+
p = sub.add_parser(
|
|
406
|
+
"start", help="start the loop: the resident tick on Slurm, the local loop elsewhere"
|
|
407
|
+
)
|
|
408
|
+
p.add_argument(
|
|
409
|
+
"--root", help="state root (shared filesystem on Slurm; default ~/.autoresearch locally)"
|
|
410
|
+
)
|
|
411
|
+
p.add_argument("--account", help="Slurm account")
|
|
412
|
+
p.add_argument("--partition", help="Slurm partition for the tick")
|
|
413
|
+
p.add_argument(
|
|
414
|
+
"--local", action="store_true", help="run the local loop even where sbatch exists"
|
|
415
|
+
)
|
|
416
|
+
p.add_argument("--dry-run", action="store_true", help="print the command and exit")
|
|
417
|
+
sub.add_parser("tick", help="one tick, or --loop; the chain's own entry", add_help=False)
|
|
418
|
+
sub.add_parser(
|
|
419
|
+
"init",
|
|
420
|
+
help="guided setup: write ~/.config/outerloop/.env and the PAT file",
|
|
421
|
+
add_help=False,
|
|
422
|
+
)
|
|
423
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
424
|
+
if argv[:1] == ["tick"]:
|
|
425
|
+
# the tick entry owns its own parser; hand it the rest untouched
|
|
426
|
+
from outerloop import tick
|
|
427
|
+
|
|
428
|
+
sys.argv = ["outerloop tick", *argv[1:]]
|
|
429
|
+
return tick.main()
|
|
430
|
+
if argv[:1] == ["init"]:
|
|
431
|
+
# init owns its own parser too; hand it the args after "init"
|
|
432
|
+
from outerloop import init
|
|
433
|
+
|
|
434
|
+
return init.main(argv[1:])
|
|
435
|
+
return start(parser.parse_args(argv))
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
if __name__ == "__main__":
|
|
439
|
+
sys.exit(main())
|