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.
Files changed (59) hide show
  1. outerloop/__init__.py +18 -0
  2. outerloop/__main__.py +3 -0
  3. outerloop/appauth.py +230 -0
  4. outerloop/appmanifest.py +203 -0
  5. outerloop/attempt.py +3784 -0
  6. outerloop/brief.py +528 -0
  7. outerloop/cli.py +621 -0
  8. outerloop/climbboard.py +1395 -0
  9. outerloop/compute.py +654 -0
  10. outerloop/contract.py +492 -0
  11. outerloop/contract_cli.py +63 -0
  12. outerloop/disk.py +164 -0
  13. outerloop/dispatch.py +631 -0
  14. outerloop/evalcache.py +147 -0
  15. outerloop/followup.py +2172 -0
  16. outerloop/github.py +1531 -0
  17. outerloop/harness.py +1435 -0
  18. outerloop/housekeeping.py +151 -0
  19. outerloop/image.py +368 -0
  20. outerloop/init.py +744 -0
  21. outerloop/intake.py +126 -0
  22. outerloop/launchlog.py +239 -0
  23. outerloop/limits.py +80 -0
  24. outerloop/maintain.py +353 -0
  25. outerloop/maintain_agent_cli.py +81 -0
  26. outerloop/maintain_post_cli.py +140 -0
  27. outerloop/markers.py +48 -0
  28. outerloop/measure.py +529 -0
  29. outerloop/orchestrator.py +2011 -0
  30. outerloop/panel.py +188 -0
  31. outerloop/paths.py +40 -0
  32. outerloop/posting.py +160 -0
  33. outerloop/progress.py +170 -0
  34. outerloop/py.typed +0 -0
  35. outerloop/review.py +615 -0
  36. outerloop/review_agent.py +263 -0
  37. outerloop/review_agent_cli.py +209 -0
  38. outerloop/review_post_cli.py +162 -0
  39. outerloop/review_summarize_cli.py +165 -0
  40. outerloop/role_runner.py +229 -0
  41. outerloop/roles.py +274 -0
  42. outerloop/rolespec.py +91 -0
  43. outerloop/runstate.py +385 -0
  44. outerloop/steward.py +845 -0
  45. outerloop/style.py +12 -0
  46. outerloop/syscall.py +1192 -0
  47. outerloop/syscall_cli.py +762 -0
  48. outerloop/tick.py +3422 -0
  49. outerloop/verifier.py +403 -0
  50. outerloop/verify_agent.py +151 -0
  51. outerloop/verify_agent_cli.py +95 -0
  52. outerloop/verify_post_cli.py +116 -0
  53. outerloop/watcher.py +203 -0
  54. outerloop_science-0.1.0.dist-info/METADATA +152 -0
  55. outerloop_science-0.1.0.dist-info/RECORD +59 -0
  56. outerloop_science-0.1.0.dist-info/WHEEL +4 -0
  57. outerloop_science-0.1.0.dist-info/entry_points.txt +2 -0
  58. outerloop_science-0.1.0.dist-info/licenses/LICENSE +202 -0
  59. 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())