From 852131997c12c2e27ccae89dca39d1cb8a0daeb3 Mon Sep 17 00:00:00 2001 From: Mengye Ren Date: Wed, 30 Sep 2026 16:41:04 -0400 Subject: [PATCH 1/4] Override validation uses the image the tick runs with (default when OUTERLOOP_IMAGE is unset) Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 ++++ src/outerloop/cli.py | 6 +++++- src/outerloop/tick.py | 10 ++++++++-- tests/test_author_overrides.py | 35 ++++++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02d795c5..3bc18bae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Versions follow [SemVer](https://semver.org). ## [Unreleased] +- Startup validation of `OUTERLOOP_AUTHOR_OVERRIDES` (the tick and `outerloop start`) uses the + image sessions actually run with, the default image when `OUTERLOOP_IMAGE` is unset. Before, a + codex override on a deployment without `OUTERLOOP_IMAGE` failed validation and stopped the tick. + - `OUTERLOOP_AUTHOR_OVERRIDES` accepts a list of entries per target, so different slots of one target can use different authors (each listed entry names its slots; a slot may appear only once). The single-object form is unchanged. diff --git a/src/outerloop/cli.py b/src/outerloop/cli.py index 0fe20f31..4541289a 100644 --- a/src/outerloop/cli.py +++ b/src/outerloop/cli.py @@ -612,8 +612,12 @@ def start(args: argparse.Namespace) -> int: from outerloop.author_overrides import validate_overrides try: + from outerloop.tick import _default_image + + # the image the tick will run with: an unset setting means the default image validate_overrides( - {**values, **os.environ}, _setting_of("OUTERLOOP_IMAGE", values, os.environ) + {**values, **os.environ}, + _setting_of("OUTERLOOP_IMAGE", values, os.environ) or _default_image(), ) except ValueError as exc: raise StartError(str(exc)) from exc diff --git a/src/outerloop/tick.py b/src/outerloop/tick.py index f22de6bd..b407544c 100644 --- a/src/outerloop/tick.py +++ b/src/outerloop/tick.py @@ -3440,6 +3440,12 @@ def _default_image() -> str: return os.path.expanduser("~/outerloop-images/agent-py312.sif") +def startup_image() -> str: + """The image sessions run with: OUTERLOOP_IMAGE, else the default. Startup + validation uses the same value, so it can never refuse what the tick would run.""" + return os.environ.get("OUTERLOOP_IMAGE", _default_image()) + + def _service_spec_from_env( root: Path, *, gpu_lanes: dict[str, GpuLane] | None = None ) -> tuple[Any, ServiceSpec | None]: @@ -3452,7 +3458,7 @@ def _service_spec_from_env( account = os.environ.get("OUTERLOOP_ACCOUNT", "") partition = os.environ.get("OUTERLOOP_PARTITION", "") qos = os.environ.get("OUTERLOOP_QOS", "") - image = os.environ.get("OUTERLOOP_IMAGE", _default_image()) + image = startup_image() home = os.environ.get("OUTERLOOP_HOME", "") # Account and partition are optional on Slurm: empty ones leave the billing # association and the partition to Slurm's defaults, as `start` already @@ -3588,7 +3594,7 @@ def main() -> int: try: gpu_lanes = gpu_lanes_from_env() - validate_overrides(os.environ, os.environ.get("OUTERLOOP_IMAGE", "")) + validate_overrides(os.environ, startup_image()) except ValueError as exc: parser.error(str(exc)) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s") diff --git a/tests/test_author_overrides.py b/tests/test_author_overrides.py index b00b89c6..8a6dfdaf 100644 --- a/tests/test_author_overrides.py +++ b/tests/test_author_overrides.py @@ -433,3 +433,38 @@ def test_a_target_may_list_overrides_for_different_slots(monkeypatch): def test_listed_overrides_are_validated(entries): with pytest.raises(ValueError, match=r"^OUTERLOOP_AUTHOR_OVERRIDES:"): parse_overrides(json.dumps({"owner/repo": entries})) + + +def test_startup_validation_uses_the_image_the_tick_runs(monkeypatch): + # A deployment that leaves OUTERLOOP_IMAGE unset runs codex sessions on the default + # image; startup validation of a codex override must use that same image, or the + # tick refuses to start at all. + import os + + from outerloop import tick + from outerloop.author_overrides import validate_overrides + + monkeypatch.delenv("OUTERLOOP_IMAGE", raising=False) + monkeypatch.setenv( + "OUTERLOOP_AUTHOR_OVERRIDES", + json.dumps( + {"owner/repo": {"backend": "codex", "model": "some-codex-model", "slots": ["agent-03"]}} + ), + ) + assert tick.startup_image() == tick._default_image() + with pytest.raises(ValueError, match="requires --image"): + validate_overrides(os.environ, "") # the old startup behaviour + try: + validate_overrides(os.environ, tick.startup_image()) + except ValueError as exc: + assert "requires --image" not in str(exc) + + +def test_tick_startup_validates_with_startup_image(): + # The tick's entry point must validate overrides with the image sessions run with. + import inspect + + from outerloop import tick + + src = inspect.getsource(tick.main) + assert "validate_overrides(os.environ, startup_image())" in src From 1346a6cbbc43955cf9c17f4a3ccd77a0e7ca0f03 Mon Sep 17 00:00:00 2001 From: Mengye Ren Date: Wed, 30 Sep 2026 16:56:00 -0400 Subject: [PATCH 2/4] start validates with the tick's image: absent means default, explicit empty means none Co-Authored-By: Claude Opus 5.5 (1M context) --- src/outerloop/cli.py | 8 ++++++-- tests/test_author_overrides.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/outerloop/cli.py b/src/outerloop/cli.py index 4541289a..ea9ea8d3 100644 --- a/src/outerloop/cli.py +++ b/src/outerloop/cli.py @@ -614,10 +614,14 @@ def start(args: argparse.Namespace) -> int: try: from outerloop.tick import _default_image - # the image the tick will run with: an unset setting means the default image + # the image the launched tick will run with: absent means the default image, an + # explicit empty value means no image (as tick.startup_image treats it) + image_set = "OUTERLOOP_IMAGE" in os.environ or "OUTERLOOP_IMAGE" in values validate_overrides( {**values, **os.environ}, - _setting_of("OUTERLOOP_IMAGE", values, os.environ) or _default_image(), + _setting_of("OUTERLOOP_IMAGE", values, os.environ) + if image_set + else _default_image(), ) except ValueError as exc: raise StartError(str(exc)) from exc diff --git a/tests/test_author_overrides.py b/tests/test_author_overrides.py index 8a6dfdaf..f6ccbdee 100644 --- a/tests/test_author_overrides.py +++ b/tests/test_author_overrides.py @@ -468,3 +468,32 @@ def test_tick_startup_validates_with_startup_image(): src = inspect.getsource(tick.main) assert "validate_overrides(os.environ, startup_image())" in src + + +@pytest.mark.parametrize( + ("env_value", "expected"), [(None, "DEFAULT"), ("", ""), ("/img.sif", "/img.sif")] +) +def test_start_validates_with_the_tick_image(monkeypatch, tmp_path, env_value, expected): + # outerloop start must validate overrides with exactly the image the launched tick uses: + # absent -> the default image, explicit empty -> no image, set -> that image. + import contextlib + + from outerloop import cli, tick + + seen = [] + monkeypatch.setattr( + "outerloop.author_overrides.validate_overrides", lambda env, image: seen.append(image) + ) + monkeypatch.setattr(tick, "_default_image", lambda: "DEFAULT") + if env_value is None: + monkeypatch.delenv("OUTERLOOP_IMAGE", raising=False) + else: + monkeypatch.setenv("OUTERLOOP_IMAGE", env_value) + env_file = tmp_path / "settings.env" + env_file.write_text("") + env_file.chmod(0o600) + monkeypatch.setattr(cli, "ENV_FILE", env_file) + monkeypatch.setenv("OUTERLOOP_ENV_FILE", str(env_file)) + with contextlib.suppress(SystemExit): + cli.main(["start", "--dry-run", "--root", str(tmp_path / "state")]) + assert seen and seen[0] == expected From 47fd78175a7a7218543c9d0e33f05bd61a664206 Mon Sep 17 00:00:00 2001 From: Mengye Ren Date: Wed, 30 Sep 2026 17:23:12 -0400 Subject: [PATCH 3/4] start resolves the image in the deploy step's order: settings file, then environment, then default Co-Authored-By: Claude Opus 5.5 (1M context) --- src/outerloop/cli.py | 19 ++++++++++--------- tests/test_author_overrides.py | 15 ++++++++++++--- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/outerloop/cli.py b/src/outerloop/cli.py index ea9ea8d3..913a9be1 100644 --- a/src/outerloop/cli.py +++ b/src/outerloop/cli.py @@ -614,15 +614,16 @@ def start(args: argparse.Namespace) -> int: try: from outerloop.tick import _default_image - # the image the launched tick will run with: absent means the default image, an - # explicit empty value means no image (as tick.startup_image treats it) - image_set = "OUTERLOOP_IMAGE" in os.environ or "OUTERLOOP_IMAGE" in values - validate_overrides( - {**values, **os.environ}, - _setting_of("OUTERLOOP_IMAGE", values, os.environ) - if image_set - else _default_image(), - ) + # the image the launched tick will run with, in the deploy step's order: a line in + # the settings file wins (even empty, which means no image), then the inherited + # environment, then the default image (as tick.startup_image falls back) + if "OUTERLOOP_IMAGE" in values: + image = values["OUTERLOOP_IMAGE"].strip() + elif "OUTERLOOP_IMAGE" in os.environ: + image = os.environ["OUTERLOOP_IMAGE"].strip() + else: + image = _default_image() + validate_overrides({**values, **os.environ}, image) except ValueError as exc: raise StartError(str(exc)) from exc problem = "" if args.dry_run else missing_harness_binary(values, os.environ) diff --git a/tests/test_author_overrides.py b/tests/test_author_overrides.py index f6ccbdee..1eac692c 100644 --- a/tests/test_author_overrides.py +++ b/tests/test_author_overrides.py @@ -471,9 +471,18 @@ def test_tick_startup_validates_with_startup_image(): @pytest.mark.parametrize( - ("env_value", "expected"), [(None, "DEFAULT"), ("", ""), ("/img.sif", "/img.sif")] + ("env_value", "file_value", "expected"), + [ + (None, None, "DEFAULT"), + ("", None, ""), + ("/img.sif", None, "/img.sif"), + ("/inherited.sif", "", ""), # the settings file wins, as in the deploy step + ("/inherited.sif", "/file.sif", "/file.sif"), + ], ) -def test_start_validates_with_the_tick_image(monkeypatch, tmp_path, env_value, expected): +def test_start_validates_with_the_tick_image( + monkeypatch, tmp_path, env_value, file_value, expected +): # outerloop start must validate overrides with exactly the image the launched tick uses: # absent -> the default image, explicit empty -> no image, set -> that image. import contextlib @@ -490,7 +499,7 @@ def test_start_validates_with_the_tick_image(monkeypatch, tmp_path, env_value, e else: monkeypatch.setenv("OUTERLOOP_IMAGE", env_value) env_file = tmp_path / "settings.env" - env_file.write_text("") + env_file.write_text("" if file_value is None else f"OUTERLOOP_IMAGE={file_value}\n") env_file.chmod(0o600) monkeypatch.setattr(cli, "ENV_FILE", env_file) monkeypatch.setenv("OUTERLOOP_ENV_FILE", str(env_file)) From 453ccce09517c73f9623ae2f97a4bea836f7ed90 Mon Sep 17 00:00:00 2001 From: Mengye Ren Date: Wed, 30 Sep 2026 17:34:27 -0400 Subject: [PATCH 4/4] start validates with the image its launched tick uses, per launch mode Co-Authored-By: Claude Opus 5.5 (1M context) --- src/outerloop/cli.py | 39 +++++++++++++++++++--------------- tests/test_author_overrides.py | 39 +++++++++++++++++++++++----------- 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/src/outerloop/cli.py b/src/outerloop/cli.py index 913a9be1..ed875e7f 100644 --- a/src/outerloop/cli.py +++ b/src/outerloop/cli.py @@ -417,6 +417,21 @@ def _setting_of(key: str, values: Mapping[str, str], environ: Mapping[str, str]) return (environ[key] if key in environ else values.get(key, "")).strip() +def _tick_image(values: Mapping[str, str], environ: Mapping[str, str], mode: str) -> str: + """The image the launched tick runs with, resolved the way its launch mode passes + settings on: the resident chain's deploy step lets a settings-file line win (even + empty, meaning no image); a local or login loop keeps the shell's value and fills + only what is missing from the file. Absent everywhere: the default image.""" + from outerloop.tick import _default_image + + key = "OUTERLOOP_IMAGE" + first, second = (values, environ) if mode == "slurm" else (environ, values) + for source in (first, second): + if key in source: + return source[key].strip() + return _default_image() + + def missing_harness_binary(values: Mapping[str, str], environ: Mapping[str, str]) -> str: """Check all configured authors' host CLIs using the harness's lookup.""" from outerloop.author_overrides import override_entries @@ -609,23 +624,6 @@ def start(args: argparse.Namespace) -> int: values = env_file_values( operator_env_file(ENV_FILE), START_KEYS + TICK_ENV_KEYS ) # one read for everything - from outerloop.author_overrides import validate_overrides - - try: - from outerloop.tick import _default_image - - # the image the launched tick will run with, in the deploy step's order: a line in - # the settings file wins (even empty, which means no image), then the inherited - # environment, then the default image (as tick.startup_image falls back) - if "OUTERLOOP_IMAGE" in values: - image = values["OUTERLOOP_IMAGE"].strip() - elif "OUTERLOOP_IMAGE" in os.environ: - image = os.environ["OUTERLOOP_IMAGE"].strip() - else: - image = _default_image() - validate_overrides({**values, **os.environ}, image) - except ValueError as exc: - raise StartError(str(exc)) from exc problem = "" if args.dry_run else missing_harness_binary(values, os.environ) try: author_model_setting( @@ -652,6 +650,13 @@ def start(args: argparse.Namespace) -> int: sbatch_on_path=shutil.which("sbatch") is not None, cwd=Path.cwd(), ) + from outerloop.author_overrides import validate_overrides + + try: + # validate with the image the tick this start launches will actually run with + validate_overrides({**values, **os.environ}, _tick_image(values, os.environ, plan.mode)) + except ValueError as exc: + raise StartError(str(exc)) from exc except (StartError, ValueError, OSError) as e: print(f"outerloop start: {e}", file=sys.stderr) return 2 diff --git a/tests/test_author_overrides.py b/tests/test_author_overrides.py index 1eac692c..08921383 100644 --- a/tests/test_author_overrides.py +++ b/tests/test_author_overrides.py @@ -471,22 +471,23 @@ def test_tick_startup_validates_with_startup_image(): @pytest.mark.parametrize( - ("env_value", "file_value", "expected"), + ("mode", "env_value", "file_value", "expected"), [ - (None, None, "DEFAULT"), - ("", None, ""), - ("/img.sif", None, "/img.sif"), - ("/inherited.sif", "", ""), # the settings file wins, as in the deploy step - ("/inherited.sif", "/file.sif", "/file.sif"), + ("local", None, None, "DEFAULT"), + ("local", "", None, ""), + ("local", "/img.sif", None, "/img.sif"), + ("local", "/inherited.sif", "", "/inherited.sif"), # a local loop keeps the shell's value + ("local", None, "/file.sif", "/file.sif"), + ("slurm", None, None, "DEFAULT"), + ("slurm", "/inherited.sif", "", ""), # the deploy step lets the settings file win + ("slurm", "/inherited.sif", "/file.sif", "/file.sif"), + ("slurm", "/inherited.sif", None, "/inherited.sif"), ], ) -def test_start_validates_with_the_tick_image( - monkeypatch, tmp_path, env_value, file_value, expected +def test_start_validates_with_the_launched_tick_image( + monkeypatch, tmp_path, mode, env_value, file_value, expected ): - # outerloop start must validate overrides with exactly the image the launched tick uses: - # absent -> the default image, explicit empty -> no image, set -> that image. - import contextlib - + # outerloop start must validate overrides with exactly the image the tick it launches uses. from outerloop import cli, tick seen = [] @@ -503,6 +504,20 @@ def test_start_validates_with_the_tick_image( env_file.chmod(0o600) monkeypatch.setattr(cli, "ENV_FILE", env_file) monkeypatch.setenv("OUTERLOOP_ENV_FILE", str(env_file)) + monkeypatch.delenv("OUTERLOOP_COMPUTE", raising=False) + monkeypatch.delenv("OUTERLOOP_TICK_HOST", raising=False) + real_which = cli.shutil.which + monkeypatch.setattr( + cli.shutil, + "which", + lambda name, *a, **k: ( + ("/usr/bin/sbatch" if mode == "slurm" else None) + if name == "sbatch" + else real_which(name, *a, **k) + ), + ) + import contextlib + with contextlib.suppress(SystemExit): cli.main(["start", "--dry-run", "--root", str(tmp_path / "state")]) assert seen and seen[0] == expected