diff --git a/.console/backlog.md b/.console/backlog.md index d1357f1c..22420c37 100644 --- a/.console/backlog.md +++ b/.console/backlog.md @@ -2,8 +2,53 @@ _Durable work inventory. Update after each meaningful chunk of progress._ +## Up Next + +### CritiqueExecutor is not covered by the fleet-launch self-heal +- `ensure_executor_backends()` in `scripts/operations-center.sh` probes only + `import team_executor, dag_executor` and reinstalls only `../TeamExecutor` and + `../DAGExecutor`. `critique_executor` is a third backend OC loads + (`backends/critique_executor/adapter.py`), so a `uv sync` / venv-recreate that + drops it is NOT auto-repaired at fleet launch — every critique-topology task + fails at execute with `No module named 'critique_executor'` until a human notices. +- Setup (`entrypoints/setup/main.py`) now covers all three via `EXECUTOR_BACKENDS`; + the shell script is the remaining gap. Fix is to widen the probe and the sibling + loop to match, ideally sourcing the same list. +- Deferred from the 2026-08-03 setup fix to keep fleet-startup behavior out of that + change's blast radius. + ## Done +### 2026-08-03: Replace setup's dead executor PATH probe with an importability check (✅ COMPLETE) +- **Objective**: `ensure_executor_installed`/`verify_executor` in + `entrypoints/setup/main.py` gated interactive setup on a `team-executor` console + script that TeamExecutor never produces (no `[project.scripts]`), so the wizard + hard-failed at that step on every run. Replace with a check of what OC actually + needs: importability of the three backends it loads as libraries. +- **Status**: ✅ COMPLETE. +- **Changes**: + - `setup/main.py` — new `EXECUTOR_BACKENDS` table, `missing_executor_backends()` + (subprocess import probe) and `ensure_executor_backends_installed()` (editable + install of `../TeamExecutor`, `../DAGExecutor`, `../CritiqueExecutor` + re-probe), + mirroring `ensure_executor_backends()` in `scripts/operations-center.sh`. + Removed `ensure_executor_installed`, `verify_executor`, the "Executor binary" + prompt, and `SetupAnswers.executor_binary`. + - `maintenance/dependency_check.py` — same stale-CLI bug: `team-executor --version` + replaced with `executor_backend_status()` (importability + distribution version); + `kind` `"cli"` → `"library"`. + - Docs — `docs/operator/setup.md` "Executor Install Behavior" + Executor/Advanced Mode + bullets rewritten; `docs/demo.md` PATH prerequisite corrected. +- **Config-key decisions**: `team_executor.binary` removed (no writer, no settings + field, no reader outside setup's own prompt default). `OPERATIONS_CENTER_EXECUTOR_INSTALL_REF` + kept but repurposed as a drift-reporting version pin — it still has a live consumer + in `dependency_check.py`, but nothing installs from it anymore. +- **Verification**: probed the live WSL2 venv — `missing_executor_backends()` returns + `[]`, all three backends report `(True, '0.1.0')`, `shutil.which("team-executor")` + is `None` (confirming the old gate could never pass). 10 new tests; 26 pass across + the two touched test files; `ruff check`/`ruff format --check` clean. Full suite: + 10354 passed, 6 failed — the same pre-existing sandbox/timing failures as prior + stages, all reproduced on an unmodified checkout. + ### 2026-07-15: Stage 4 — Refactor existing code to use the new shared helper (✅ COMPLETE) - **Objective**: Independently re-verify Stage 2's migration against the "refactor existing code" acceptance bar (identified/updated all relevant callsites, replaced redundant diff --git a/.console/log.md b/.console/log.md index aae1f04d..8fefd195 100644 --- a/.console/log.md +++ b/.console/log.md @@ -1,3 +1,65 @@ +## 2026-08-03 — fix(setup): replace the dead executor PATH probe with an importability check + +`entrypoints/setup/main.py` gated the whole wizard on a step that could never +pass: `ensure_executor_installed("team-executor")` shelled out to `uv tool +install git+.../TeamExecutor@dev --force`, then re-checked PATH and raised +`[executor] ERROR: installation failed` if the binary still wasn't there — +followed by `verify_executor` running `team-executor --help`. TeamExecutor +declares no `[project.scripts]`, so no `team-executor` console script is ever +produced. Verified against the live WSL2 stack: `shutil.which("team-executor")` +is `None`. Every interactive setup run therefore hard-failed at that gate, after +the uv install had already burned a network fetch. + +The probe was measuring the wrong thing. OC consumes all three execute backends +as LIBRARIES — `backends/{team_executor,dag_executor,critique_executor}/adapter.py` +each do a plain `import ` — so importability in OC's venv is the only +readiness signal that means anything. PATH is not: TeamExecutor and +CritiqueExecutor ship no console script at all, and the one that exists +(DAGExecutor's `dag-executor`) is never invoked by OC. + +Replaced with `missing_executor_backends()` + `ensure_executor_backends_installed()`, +mirroring the `ensure_executor_backends()` self-heal in `scripts/operations-center.sh`: +probe each backend with ` -c "import "`, and for anything +missing install the sibling checkout editable (`../TeamExecutor`, `../DAGExecutor`, +`../CritiqueExecutor`), then re-probe. Setup now covers all THREE backends; the +shell self-heal still only covers two (`team_executor`, `dag_executor`) — a +CritiqueExecutor drop mid-life is not yet auto-repaired at fleet launch. Left +alone deliberately (fleet-startup behavior, out of this change's blast radius); +flagged for follow-up. The probe runs in a subprocess, not via importlib in-process, +so an install that lands partway through setup is visible to the re-check. + +Config-key decisions: + +* `team_executor.binary` — REMOVED. It had no consumer in either direction: + `TeamExecutorSettings` has no `binary` field, `render_settings_yaml` never + wrote the key, and the only reader was setup's own prompt default. Dropped the + prompt and the `SetupAnswers.executor_binary` field. +* `OPERATIONS_CENTER_EXECUTOR_INSTALL_REF` — KEPT, repurposed. It does have a + live consumer (`entrypoints/maintenance/dependency_check.py`), but its old + meaning ("git ref to install from") died with `ensure_executor_installed`. + Relabeled as a version pin for drift reporting, which is what dependency-check + actually does with it and how the docs already grouped it (alongside the Plane + and provider CLI pins). + +Same stale-CLI bug had a second instance: `collect_dependency_statuses` probed +`team-executor --version`, so the TeamExecutor row reported +`healthy=False` / "not installed or not on PATH" on every single run, forever. +Replaced with `executor_backend_status()` (importability + best-effort +distribution version via `packages_distributions()`); `kind` corrected +`"cli"` → `"library"`. Verified against the live WSL2 venv: all three backends +report `(True, '0.1.0')` — the editable-install version lookup resolves. + +Tests: 7 new in `test_setup_cli.py` (probe call shape, no-op when all +importable, editable install of missing siblings, missing-checkout error, +install-failure error, still-unimportable-after-install error, backend-list pin) +and 3 in `test_dependency_check.py`. 26 pass in the two touched files; full suite +10354 passed with the same 6 pre-existing sandbox/timing failures as prior +stages (reproduced on an unmodified checkout — none related). + +Docs: rewrote `docs/operator/setup.md` "Executor Install Behavior" to describe +the import-based flow, fixed the "install/verify `team-executor` CLI" bullet and +the Advanced Mode pin description, and corrected the `docs/demo.md` prerequisite +that told operators to put `team-executor` on PATH. ## 2026-08-03 — fix(hooks): pre-push resolved the wrong workspace root inside a git worktree `.hooks/pre-push` locates the boundary disclosure artifact by globbing sibling diff --git a/docs/demo.md b/docs/demo.md index 357005c9..4227a6b0 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -218,7 +218,8 @@ Full end-to-end walkthrough from local startup to a completed task with retained - Python 3.11+ - A GitHub account with a repo and a personal access token (repo scope) - `gh` CLI authenticated (`gh auth login`) or a `GITHUB_TOKEN` PAT -- TeamExecutor (`team-executor`) installed and accessible via PATH +- TeamExecutor, DAGExecutor, and CritiqueExecutor cloned as siblings of this repo + (setup installs them editable into the OC venv — they are imported, not run from PATH) ### Step 1 — First-time setup diff --git a/docs/operator/setup.md b/docs/operator/setup.md index 0285a6df..fd869e8a 100644 --- a/docs/operator/setup.md +++ b/docs/operator/setup.md @@ -73,7 +73,7 @@ source .env.operations-center.local TeamExecutor is the multi-agent coding engine OperationsCenter uses for task execution. See `src/operations_center/backends/team_executor/` for the adapter implementation. -- install/verify `team-executor` CLI +- verify the execute backends are importable, installing missing sibling checkouts editable - configure orchestrator defaults - persist local execution settings @@ -111,24 +111,39 @@ so they work regardless of which venv was activated during bootstrap. ## Executor Install Behavior +OperationsCenter loads its execute backends as **libraries**, not CLIs — the adapters in +`src/operations_center/backends//` do a plain `import team_executor` / `import dag_executor` / +`import critique_executor`. So readiness means "importable in the OC venv", not "on `PATH`". +None of the three ships a console script OC invokes. + +The backends are sibling *checkouts*, not declared OC dependencies: `uv pip install -e .[dev]` +never installs them, and a `uv sync` or venv recreate actively drops them. + Setup: -- checks whether `team-executor` is on `PATH` -- installs `uv` if needed -- installs TeamExecutor if missing -- verifies the install with `team-executor --help` +- probes each backend with ` -c "import "` +- installs `uv` if needed, and only if a backend is actually missing +- installs the missing backend editable from its sibling checkout + (`../TeamExecutor`, `../DAGExecutor`, `../CritiqueExecutor`) +- fails with the expected checkout path if a sibling is not cloned next to this repo +- re-probes after installing and fails if a backend is still not importable -Setup is intended to be idempotent: it does not reinstall the executor when the current install already works. +Setup is idempotent: the import probe is cheap and the install only fires for backends that +are actually missing. `scripts/operations-center.sh` runs the same self-heal +(`ensure_executor_backends`) at every fleet launch, so a mid-life drop recovers on the next start. ## Advanced Mode Advanced mode also exposes optional version pins for: - Plane -- TeamExecutor +- TeamExecutor (`OPERATIONS_CENTER_EXECUTOR_INSTALL_REF`) - supported provider CLIs -Pins are for reproducible local installs. They do not automatically trigger update checks during normal runs. +Pins record the version this machine is expected to run. They do not automatically trigger update +checks during normal runs, and the TeamExecutor pin does not drive an install — the backend comes +from the sibling checkout. `dependency-check` compares each pin against what is installed and +against the upstream latest release, and reports the drift. ## Per-Repo Reviewer Settings diff --git a/src/operations_center/entrypoints/maintenance/dependency_check.py b/src/operations_center/entrypoints/maintenance/dependency_check.py index 58f61ec4..5025f987 100644 --- a/src/operations_center/entrypoints/maintenance/dependency_check.py +++ b/src/operations_center/entrypoints/maintenance/dependency_check.py @@ -3,10 +3,11 @@ from __future__ import annotations import argparse +import importlib.metadata +import importlib.util import json import os import re -import subprocess import uuid from dataclasses import asdict, dataclass from pathlib import Path @@ -80,6 +81,31 @@ def plane_latest_from_env(env: dict[str, str]) -> tuple[str | None, str | None]: return pinned, setup_url +def executor_backend_status(module: str) -> tuple[bool, str | None]: + """Return ``(importable, distribution version)`` for an execute backend module. + + OC loads TeamExecutor as a LIBRARY (``backends/team_executor/adapter.py`` + imports it directly), and TeamExecutor declares no ``[project.scripts]`` — so + importability, not PATH, is what "installed" means here. The version is + best-effort: an editable sibling checkout whose metadata does not map the + top-level module back to a distribution reports importable with no version. + """ + try: + importable = importlib.util.find_spec(module) is not None + except (ImportError, ValueError): + importable = False + if not importable: + return False, None + candidates = list(importlib.metadata.packages_distributions().get(module, ())) + candidates.append(module.replace("_", "-")) + for distribution in candidates: + try: + return True, normalize_version(importlib.metadata.version(distribution)) + except importlib.metadata.PackageNotFoundError: + continue + return True, None + + def current_plane_health(settings: Settings) -> bool: try: response = httpx.get(settings.plane.base_url, timeout=10.0) @@ -114,20 +140,15 @@ def collect_dependency_statuses(settings: Settings, env: dict[str, str]) -> list ) ) - try: - proc = subprocess.run( - ["team-executor", "--version"], check=False, capture_output=True, text=True, timeout=10 - ) - executor_version_raw = (proc.stdout or proc.stderr).strip() if proc.returncode == 0 else "" - except Exception: - executor_version_raw = "" - executor_installed = bool(executor_version_raw) - executor_installed_version = normalize_version(executor_version_raw) + executor_installed, executor_installed_version = executor_backend_status("team_executor") executor_pinned = normalize_version(env.get("OPERATIONS_CENTER_EXECUTOR_INSTALL_REF")) executor_latest = fetch_github_latest_release("ProtocolWarden", "TeamExecutor") executor_notes: list[str] = [] if not executor_installed: - executor_notes.append("team-executor is not installed or not on PATH.") + executor_notes.append( + "team_executor is not importable. Run `./scripts/operations-center.sh setup` or " + "install the sibling TeamExecutor checkout editable into the OC venv." + ) if ( executor_pinned and executor_installed_version @@ -144,7 +165,7 @@ def collect_dependency_statuses(settings: Settings, env: dict[str, str]) -> list DependencyStatus( key="team_executor", label="TeamExecutor", - kind="cli", + kind="library", installed_version=executor_installed_version, pinned_version=executor_pinned, upstream_latest=executor_latest, diff --git a/src/operations_center/entrypoints/setup/main.py b/src/operations_center/entrypoints/setup/main.py index 9fe176fe..5e28ef06 100644 --- a/src/operations_center/entrypoints/setup/main.py +++ b/src/operations_center/entrypoints/setup/main.py @@ -5,6 +5,7 @@ import os import shutil import subprocess +import sys import webbrowser from dataclasses import dataclass from pathlib import Path @@ -38,6 +39,22 @@ Path.home() / ".ssh" / "id_rsa", ] +# The execute backends OC loads, as (import name, sibling checkout dir). +# OC consumes all three as LIBRARIES — backends//adapter.py does a plain +# `import ` — so importability in OC's interpreter is the only readiness +# signal that means anything. PATH is not: TeamExecutor and CritiqueExecutor +# declare no `[project.scripts]` at all, and the one console script that does +# exist (DAGExecutor's `dag-executor`) is never invoked by OC. +# They are sibling CHECKOUTS, not declared OC dependencies, so `uv pip install +# -e .[dev]` never installs them and a `uv sync` / venv-recreate actively drops +# them. Mirrors ensure_executor_backends() in scripts/operations-center.sh, +# which self-heals the same drop at every fleet launch. +EXECUTOR_BACKENDS: tuple[tuple[str, str], ...] = ( + ("team_executor", "TeamExecutor"), + ("dag_executor", "DAGExecutor"), + ("critique_executor", "CritiqueExecutor"), +) + @dataclass class RepoSetupAnswers: @@ -78,7 +95,9 @@ class SetupAnswers: git_author_email: str git_sign_commits: bool git_signing_key: str | None - executor_binary: str + # Version pin recorded for drift reporting only (dependency-check compares it + # against the installed backend and the upstream latest release). Nothing + # installs from it — the backends come from sibling checkouts. executor_install_ref: str | None executor_team: str executor_cycles: int @@ -189,41 +208,70 @@ def ensure_uv_installed() -> None: raise typer.BadParameter("[executor] ERROR: uv installation failed") -def ensure_executor_installed(binary: str, install_ref: str | None = None) -> None: - typer.echo("[executor] Checking installation...") - if check_command_installed(binary): - typer.echo("[executor] already installed") - return - if binary != "team-executor": - raise typer.BadParameter( - f"[executor] ERROR: custom executor binary '{binary}' is not on PATH and automatic install only supports 'team-executor'" +def missing_executor_backends(python_binary: str | None = None) -> list[str]: + """Return the import names of EXECUTOR_BACKENDS ``python_binary`` cannot import. + + Probed in a subprocess rather than via importlib in this process: an install + that lands partway through setup must be visible to the re-check, and the + parent's import caches (and any already-bound ``sys.modules`` entry) would + still answer for the pre-install state. + """ + interpreter = python_binary or sys.executable + missing: list[str] = [] + for module, _checkout in EXECUTOR_BACKENDS: + proc = subprocess.run( + [interpreter, "-c", f"import {module}"], + check=False, + capture_output=True, + text=True, + env=os.environ.copy(), ) - ensure_uv_installed() - typer.echo("[executor] Installing via uv (ProtocolWarden TeamExecutor)...") - # Use the ProtocolWarden TeamExecutor repo. - # install_ref overrides the default branch for pinned installs. - fork_url = "git+https://github.com/ProtocolWarden/TeamExecutor.git" - ref = install_ref or "dev" - target = f"{fork_url}@{ref}" - proc = subprocess.run( - ["uv", "tool", "install", target, "--force"], - check=False, - env=os.environ.copy(), - ) - prepend_local_bin_to_path() - if proc.returncode != 0 or not check_command_installed(binary): - raise typer.BadParameter("[executor] ERROR: installation failed") - typer.echo("[executor] installed successfully") + if proc.returncode != 0: + missing.append(module) + return missing + + +def ensure_executor_backends_installed(repo_root: Path, python_binary: str | None = None) -> None: + """Make every execute backend importable, installing sibling checkouts if not. + + Idempotent: the import probe is ~free and the editable install only fires for + backends that are actually missing. + """ + interpreter = python_binary or sys.executable + typer.echo("[executor] Checking backend imports...") + missing = missing_executor_backends(interpreter) + if not missing: + typer.echo("[executor] all backends importable") + return + checkout_dirs = dict(EXECUTOR_BACKENDS) + ensure_uv_installed() + for module in missing: + checkout_name = checkout_dirs[module] + checkout = (repo_root.parent / checkout_name).resolve() + if not (checkout / "pyproject.toml").is_file(): + raise typer.BadParameter( + f"[executor] ERROR: {module} is not importable and no sibling checkout was " + f"found at {checkout}. Clone {checkout_name} next to this repo and rerun setup." + ) + typer.echo(f"[executor] {module} missing -> installing {checkout} (editable)") + proc = subprocess.run( + ["uv", "pip", "install", "--python", interpreter, "-e", str(checkout)], + check=False, + env=os.environ.copy(), + ) + if proc.returncode != 0: + raise typer.BadParameter( + f"[executor] ERROR: editable install of {checkout_name} failed" + ) -def verify_executor(binary: str) -> None: - typer.echo("[executor] Verifying...") - proc = subprocess.run( - [binary, "--help"], check=False, capture_output=True, text=True, env=os.environ.copy() - ) - if proc.returncode != 0: - raise typer.BadParameter("[executor] ERROR: executor not functioning") - typer.echo("[executor] OK") + still_missing = missing_executor_backends(interpreter) + if still_missing: + raise typer.BadParameter( + "[executor] ERROR: backends still not importable after install: " + + ", ".join(still_missing) + ) + typer.echo("[executor] backends installed successfully") def verify_plane_configuration( @@ -1108,23 +1156,11 @@ def main( ) ensure_github_ssh_setup(git_author_email, Path.cwd()) - existing_executor_binary = ( - existing_config_value(existing_config, "team_executor", "binary") or "team-executor" - ) executor_install_ref = existing_env.get("OPERATIONS_CENTER_EXECUTOR_INSTALL_REF") or None - print_section("Executor Install", "Ensure the executor CLI is available before writing config.") - executor_binary = prompt_with_default( - "Executor binary", - existing_executor_binary, - note="Using saved value." - if existing_executor_binary != "team-executor" - or existing_config_value(existing_config, "team_executor", "binary") - else None, - ) if advanced_mode: executor_install_ref = ( typer.prompt( - "Executor git ref/tag/SHA for install (optional)", + "TeamExecutor version pin for drift reporting (optional)", default=executor_install_ref or "", ).strip() or None @@ -1207,8 +1243,11 @@ def main( typer.echo("[provider] Final provider summary:") typer.echo(summarize_provider_statuses(statuses)) - ensure_executor_installed(executor_binary, install_ref=executor_install_ref) - verify_executor(executor_binary) + print_section( + "Executor Backends", + "Ensure the execute backends OperationsCenter imports are available before writing config.", + ) + ensure_executor_backends_installed(Path.cwd()) usable_providers = [status.key for status in statuses if status.interactive_ready] if not usable_providers: @@ -1320,7 +1359,6 @@ def main( git_author_email=git_author_email, git_sign_commits=git_sign_commits, git_signing_key=git_signing_key, - executor_binary=executor_binary, executor_install_ref=executor_install_ref, executor_team=executor_team, executor_cycles=executor_cycles, diff --git a/tests/test_dependency_check.py b/tests/test_dependency_check.py index e9d10015..0cf028c9 100644 --- a/tests/test_dependency_check.py +++ b/tests/test_dependency_check.py @@ -1,11 +1,13 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # Copyright (C) 2026 ProtocolWarden +import importlib.metadata from types import SimpleNamespace from operations_center.entrypoints.maintenance.dependency_check import ( DependencyStatus, actionable_statuses, dependency_task_description, + executor_backend_status, normalize_version, ) @@ -15,10 +17,27 @@ def test_normalize_version_extracts_semver() -> None: assert normalize_version("v1.2.3") == "v1.2.3" +def test_executor_backend_status_reports_unimportable_module() -> None: + assert executor_backend_status("definitely_not_an_installed_backend") == (False, None) + + +def test_executor_backend_status_reports_importable_module_without_distribution() -> None: + # Importable but not backed by a distribution — the shape an editable sibling + # checkout takes when its metadata does not map the top-level module back. + assert executor_backend_status("json") == (True, None) + + +def test_executor_backend_status_reports_installed_distribution_version() -> None: + importable, version = executor_backend_status("pydantic") + + assert importable is True + assert version == normalize_version(importlib.metadata.version("pydantic")) + + def test_actionable_statuses_filters_to_items_with_notes() -> None: statuses = [ DependencyStatus( - "team-executor", "TeamExecutor", "cli", "1.0.0", "1.0.0", "1.1.0", True, [] + "team_executor", "TeamExecutor", "library", "1.0.0", "1.0.0", "1.1.0", True, [] ), DependencyStatus( "codex", diff --git a/tests/test_setup_cli.py b/tests/test_setup_cli.py index f500000f..21c3a73c 100644 --- a/tests/test_setup_cli.py +++ b/tests/test_setup_cli.py @@ -3,13 +3,20 @@ import os from pathlib import Path +import pytest +import typer + +from operations_center.entrypoints.setup import main as setup_main from operations_center.entrypoints.setup.main import ( + EXECUTOR_BACKENDS, RepoSetupAnswers, SetupAnswers, check_command_installed, default_orchestrator_for_statuses, + ensure_executor_backends_installed, github_https_to_ssh, infer_repo_key_from_clone_url, + missing_executor_backends, parse_remote_branches, prepend_local_bin_to_path, provider_default_orchestrator, @@ -41,7 +48,6 @@ def test_render_settings_yaml_contains_local_repo_bootstrap_defaults() -> None: git_author_email="bot@example.com", git_sign_commits=True, git_signing_key="ABC12345", - executor_binary="team-executor", executor_install_ref=None, executor_team="budget", executor_cycles=3, @@ -144,7 +150,6 @@ def test_render_env_file_for_subscription_mode_skips_provider_secret_export() -> git_author_email="bot@example.com", git_sign_commits=False, git_signing_key=None, - executor_binary="team-executor", executor_install_ref="v0.4.272", executor_team="full", executor_cycles=3, @@ -208,7 +213,6 @@ def test_render_settings_yaml_supports_multiple_repos() -> None: git_author_email="bot@example.com", git_sign_commits=False, git_signing_key=None, - executor_binary="team-executor", executor_install_ref=None, executor_team="budget", executor_cycles=3, @@ -273,7 +277,6 @@ def test_render_task_template_uses_default_repo() -> None: git_author_email="bot@example.com", git_sign_commits=False, git_signing_key=None, - executor_binary="team-executor", executor_install_ref=None, executor_team="budget", executor_cycles=3, @@ -396,3 +399,153 @@ def test_summarize_provider_statuses_distinguishes_states() -> None: assert "Claude Code: installed + logged in (1.0.0)" in summary assert "OpenAI Codex CLI: installed + headless ready (1.0.0)" in summary assert "Gemini CLI: not installed" in summary + + +class _FakeProc: + def __init__(self, returncode: int = 0) -> None: + self.returncode = returncode + self.stdout = "" + self.stderr = "" + + +class _FakeRunner: + """Stands in for ``subprocess.run`` during executor-backend checks. + + Import probes fail for every module in ``missing``; a successful editable + install of a sibling checkout removes the matching module from that set, so + the post-install re-probe sees the repaired state. + """ + + def __init__(self, missing: set[str], *, install_rc: int = 0, install_fixes: bool = True): + self.missing = set(missing) + self.install_rc = install_rc + self.install_fixes = install_fixes + self.calls: list[list[str]] = [] + + def __call__(self, cmd, **kwargs): # type: ignore[no-untyped-def] + args = list(cmd) + self.calls.append(args) + if len(args) >= 3 and args[1] == "-c" and args[2].startswith("import "): + module = args[2].removeprefix("import ") + return _FakeProc(1 if module in self.missing else 0) + if args[:3] == ["uv", "pip", "install"]: + if self.install_rc == 0 and self.install_fixes: + checkout = Path(args[-1]).name + for module, checkout_name in EXECUTOR_BACKENDS: + if checkout_name == checkout: + self.missing.discard(module) + return _FakeProc(self.install_rc) + return _FakeProc(0) + + @property + def install_targets(self) -> list[str]: + return [Path(call[-1]).name for call in self.calls if call[:3] == ["uv", "pip", "install"]] + + +def _make_checkouts(tmp_path: Path, *names: str) -> Path: + repo_root = tmp_path / "OperationsCenter" + repo_root.mkdir() + for name in names: + checkout = tmp_path / name + checkout.mkdir() + (checkout / "pyproject.toml").write_text("[project]\n", encoding="utf-8") + return repo_root + + +def _patch_runner(monkeypatch: pytest.MonkeyPatch, runner: _FakeRunner) -> None: + monkeypatch.setattr(setup_main.subprocess, "run", runner) + # uv is a prerequisite of the install path, not of the probe — assume present. + monkeypatch.setattr(setup_main, "check_command_installed", lambda command: True) + + +def test_missing_executor_backends_reports_unimportable_modules( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = _FakeRunner({"dag_executor"}) + _patch_runner(monkeypatch, runner) + + assert missing_executor_backends("/usr/bin/python3") == ["dag_executor"] + assert runner.calls == [ + ["/usr/bin/python3", "-c", "import team_executor"], + ["/usr/bin/python3", "-c", "import dag_executor"], + ["/usr/bin/python3", "-c", "import critique_executor"], + ] + + +def test_executor_backends_cover_the_three_adapters_oc_loads() -> None: + assert [module for module, _ in EXECUTOR_BACKENDS] == [ + "team_executor", + "dag_executor", + "critique_executor", + ] + + +def test_ensure_executor_backends_installed_is_a_noop_when_all_importable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + repo_root = _make_checkouts(tmp_path) + runner = _FakeRunner(set()) + _patch_runner(monkeypatch, runner) + + ensure_executor_backends_installed(repo_root, python_binary="/usr/bin/python3") + + assert runner.install_targets == [] + + +def test_ensure_executor_backends_installed_installs_missing_siblings_editable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + repo_root = _make_checkouts(tmp_path, "TeamExecutor", "CritiqueExecutor") + runner = _FakeRunner({"team_executor", "critique_executor"}) + _patch_runner(monkeypatch, runner) + + ensure_executor_backends_installed(repo_root, python_binary="/usr/bin/python3") + + assert runner.install_targets == ["TeamExecutor", "CritiqueExecutor"] + install_calls = [call for call in runner.calls if call[:3] == ["uv", "pip", "install"]] + assert install_calls[0][:6] == [ + "uv", + "pip", + "install", + "--python", + "/usr/bin/python3", + "-e", + ] + assert Path(install_calls[0][-1]) == (tmp_path / "TeamExecutor").resolve() + + +def test_ensure_executor_backends_installed_errors_without_a_sibling_checkout( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + repo_root = _make_checkouts(tmp_path) + _patch_runner(monkeypatch, _FakeRunner({"dag_executor"})) + + with pytest.raises(typer.BadParameter) as excinfo: + ensure_executor_backends_installed(repo_root, python_binary="/usr/bin/python3") + + assert "dag_executor" in str(excinfo.value) + assert "DAGExecutor" in str(excinfo.value) + + +def test_ensure_executor_backends_installed_errors_when_install_fails( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + repo_root = _make_checkouts(tmp_path, "TeamExecutor") + _patch_runner(monkeypatch, _FakeRunner({"team_executor"}, install_rc=1)) + + with pytest.raises(typer.BadParameter) as excinfo: + ensure_executor_backends_installed(repo_root, python_binary="/usr/bin/python3") + + assert "editable install of TeamExecutor failed" in str(excinfo.value) + + +def test_ensure_executor_backends_installed_errors_when_still_unimportable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + repo_root = _make_checkouts(tmp_path, "TeamExecutor") + _patch_runner(monkeypatch, _FakeRunner({"team_executor"}, install_fixes=False)) + + with pytest.raises(typer.BadParameter) as excinfo: + ensure_executor_backends_installed(repo_root, python_binary="/usr/bin/python3") + + assert "still not importable after install: team_executor" in str(excinfo.value)