From 036416878dc6db303be674d8a6d7aedca6b39fc1 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 01:40:06 +0200 Subject: [PATCH] feat(toolchain): provision against the target interpreter + runnability smoke When toolchain matching is enabled, install_dev_deps resolves the target's Python and provisions the venv against it (uv sync --extra dev --python , uv auto-downloads), then runs a runnability smoke (uv run pytest --collect-only) and records {python, status} in a .git/.roboco-toolchain marker the gates read. Precision over recall: only a pytest collection error (the interpreter-mismatch signature) reports 'broken'; pytest-absent / timeout report 'unknown' so a gate never blocks on an inconclusive smoke. Flag off => provisioning is unchanged. --- roboco/services/workspace.py | 119 +++++++++++++++- .../unit/services/test_workspace_toolchain.py | 130 ++++++++++++++++++ 2 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 tests/unit/services/test_workspace_toolchain.py diff --git a/roboco/services/workspace.py b/roboco/services/workspace.py index a700b287..6305689c 100644 --- a/roboco/services/workspace.py +++ b/roboco/services/workspace.py @@ -20,6 +20,7 @@ Example: import asyncio import contextlib +import json import math import os import re @@ -37,6 +38,7 @@ from roboco.config import settings from roboco.db.tables import AgentTable from roboco.logging import get_logger from roboco.models.base import Team +from roboco.services.toolchain import resolve_target_python logger = get_logger(__name__) @@ -236,6 +238,17 @@ class WorkspaceError(Exception): # against. Lives under .git/ so it never shows up in `git status` (the agent's # clean-tree checks would otherwise trip on it) and is wiped with the clone. _DEP_INSTALL_MARKER = ".git/.roboco-dep-install" +# Records the interpreter the workspace was provisioned with + whether the +# project's suite can run there. Lives inside .git/ so it never lands in the +# target repo's tracked tree. JSON: {"python": "3.14", "status": "ok"}. +_TOOLCHAIN_MARKER = ".git/.roboco-toolchain" + +# pytest exit codes the runnability smoke interprets. A collection error (2) is +# the interpreter-mismatch signature (imports fail under the wrong Python); 0/5 +# mean the suite is runnable; anything else is inconclusive (never 'broken'). +_PYTEST_OK = 0 +_PYTEST_NO_TESTS_COLLECTED = 5 +_PYTEST_COLLECTION_ERROR = 2 def _lockfile_digest(workspace: Path) -> str | None: @@ -264,9 +277,15 @@ def _lockfile_digest(workspace: Path) -> str | None: return h.hexdigest() if found else None -def _detect_dep_commands(workspace: Path) -> list[tuple[str, list[str]]]: +def _detect_dep_commands( + workspace: Path, target_python: str | None = None +) -> list[tuple[str, list[str]]]: """Return the dev-dependency install commands for this workspace. + When ``target_python`` is given (toolchain matching enabled + the target + declares a version), the Python ``uv sync`` is pinned to that interpreter + via ``--python`` so uv fetches + uses it instead of the system 3.13. + Detects project ecosystems by lockfile/manifest and returns ``(label, argv)`` tuples to run from the workspace root: @@ -285,7 +304,10 @@ def _detect_dep_commands(workspace: Path) -> list[tuple[str, list[str]]]: commands: list[tuple[str, list[str]]] = [] if (workspace / "pyproject.toml").is_file(): - commands.append(("uv sync --extra dev", ["uv", "sync", "--extra", "dev"])) + argv = ["uv", "sync", "--extra", "dev"] + if target_python: + argv += ["--python", target_python] + commands.append(("uv sync --extra dev", argv)) if (workspace / "pnpm-lock.yaml").is_file(): commands.append(("pnpm install", ["pnpm", "install", "--frozen-lockfile"])) @@ -905,7 +927,11 @@ class WorkspaceService: if not settings.workspace_install_dev_deps: return False - commands = _detect_dep_commands(workspace) + # When toolchain matching is on, provision against the target project's + # declared Python (uv resolves + fetches it) instead of the system 3.13. + target_python = self._resolve_toolchain_target(workspace) + + commands = _detect_dep_commands(workspace, target_python=target_python) if not commands: return False @@ -915,6 +941,9 @@ class WorkspaceService: "Dev-deps install skipped (lockfiles unchanged)", workspace=str(workspace), ) + # Stamp the toolchain marker the first time it's missing so a + # workspace provisioned before the flag flipped still records it. + await self._record_toolchain(workspace, target_python, only_if_missing=True) return False any_ok = False @@ -931,8 +960,92 @@ class WorkspaceService: # The install runs as root (orchestrator); hand the freshly written # .venv / node_modules back to the agent user. await asyncio.to_thread(_ensure_agent_owned, workspace) + await self._record_toolchain(workspace, target_python) return any_ok + @staticmethod + def _resolve_toolchain_target(workspace: Path) -> str | None: + """The Python version to provision with, or None (flag off / nothing + declared → today's behavior).""" + if not settings.toolchain_match_enabled: + return None + resolved = resolve_target_python(workspace) + return resolved.version if resolved else None + + async def _record_toolchain( + self, workspace: Path, python: str | None, *, only_if_missing: bool = False + ) -> None: + """Run the runnability smoke and write the toolchain marker (best-effort). + + Inert when ``python`` is None (flag off / no target version). + """ + if python is None: + return + if only_if_missing and (workspace / _TOOLCHAIN_MARKER).is_file(): + return + status = await self._run_toolchain_smoke(workspace, python) + with contextlib.suppress(OSError): + (workspace / _TOOLCHAIN_MARKER).write_text( + json.dumps({"python": python, "status": status}) + ) + + @staticmethod + async def _run_toolchain_smoke(workspace: Path, python: str) -> str: + """Can the project's suite be collected under ``python``? + + Returns ``ok`` (collected, or no tests), ``broken`` (collection/import + error — the interpreter-mismatch signature), or ``unknown`` (pytest + absent, tool missing, timeout — never block on these). Precision over + recall: only a genuine collection error reports ``broken``. + """ + argv = [ + "uv", + "run", + "--python", + python, + "python", + "-m", + "pytest", + "--collect-only", + "-q", + ] + + def _run() -> subprocess.CompletedProcess[str]: + return subprocess.run( + argv, + cwd=str(workspace), + capture_output=True, + text=True, + timeout=settings.workspace_dep_install_timeout_seconds, + check=False, + ) + + try: + result = await asyncio.to_thread(_run) + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + return "unknown" + if result.returncode in (_PYTEST_OK, _PYTEST_NO_TESTS_COLLECTED): + return "ok" + if result.returncode == _PYTEST_COLLECTION_ERROR: + return "broken" + return "unknown" + + @staticmethod + def read_toolchain_status(workspace: Path) -> tuple[str | None, str | None]: + """Read ``(python, status)`` from the workspace toolchain marker. + + ``(None, None)`` when no marker exists (flag off, not yet provisioned, + or unreadable) — callers must treat that as 'do not block'. + """ + marker = workspace / _TOOLCHAIN_MARKER + if not marker.is_file(): + return (None, None) + try: + data = json.loads(marker.read_text()) + except (OSError, json.JSONDecodeError): + return (None, None) + return (data.get("python"), data.get("status")) + @staticmethod def _dep_install_cache_hit(workspace: Path, digest: str | None) -> bool: """True when the lockfile digest matches the marker from a prior run.""" diff --git a/tests/unit/services/test_workspace_toolchain.py b/tests/unit/services/test_workspace_toolchain.py new file mode 100644 index 00000000..5055209a --- /dev/null +++ b/tests/unit/services/test_workspace_toolchain.py @@ -0,0 +1,130 @@ +"""Toolchain-matched provisioning: workspace gets the TARGET's Python. + +When ``toolchain_match_enabled`` is on and the target declares a Python version, +``install_dev_deps`` provisions the venv against that interpreter (``uv ... +--python ``), runs a runnability smoke, and records the result in a workspace +marker the gates read. When off, provisioning is exactly today's behavior. +""" + +from __future__ import annotations + +import subprocess +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from roboco.services.workspace import ( + WorkspaceService, + _detect_dep_commands, +) + +if TYPE_CHECKING: + from pathlib import Path + +_PY = '[project]\nname = "x"\nrequires-python = ">=3.14,<3.15"\n' + + +def _service() -> WorkspaceService: + session = MagicMock() + session.execute = AsyncMock() + return WorkspaceService(session) + + +def _make_workspace(tmp_path: Path) -> Path: + workspace = tmp_path / "roboco" / "backend" / "be-dev-1" + (workspace / ".git").mkdir(parents=True) + return workspace + + +def test_detect_dep_commands_injects_target_python(tmp_path: Path) -> None: + ws = _make_workspace(tmp_path) + (ws / "pyproject.toml").write_text(_PY) + commands = _detect_dep_commands(ws, target_python="3.14") + assert commands[0][1] == ["uv", "sync", "--extra", "dev", "--python", "3.14"] + + +def test_detect_dep_commands_no_python_is_unchanged(tmp_path: Path) -> None: + ws = _make_workspace(tmp_path) + (ws / "pyproject.toml").write_text(_PY) + commands = _detect_dep_commands(ws) + assert commands[0][1] == ["uv", "sync", "--extra", "dev"] + + +def _fake_run_factory(captured: list[list[str]], *, collect_rc: int): + def _fake_run(argv, **_kw) -> subprocess.CompletedProcess[str]: + captured.append(argv) + rc = collect_rc if "--collect-only" in argv else 0 + return subprocess.CompletedProcess(argv, returncode=rc, stdout="", stderr="") + + return _fake_run + + +@pytest.mark.asyncio +async def test_provisions_target_python_and_records_ok(tmp_path: Path) -> None: + ws = _make_workspace(tmp_path) + (ws / "pyproject.toml").write_text(_PY) + (ws / "uv.lock").write_text("version = 1\n") + svc = _service() + captured: list[list[str]] = [] + + with ( + patch("roboco.services.workspace.settings.toolchain_match_enabled", True), + patch( + "roboco.services.workspace.subprocess.run", + side_effect=_fake_run_factory(captured, collect_rc=0), + ), + patch("roboco.services.workspace._ensure_agent_owned"), + ): + await svc.install_dev_deps(ws) + + # provisioned against 3.14 + ran the collect smoke + assert ["uv", "sync", "--extra", "dev", "--python", "3.14"] in captured + assert any("--collect-only" in argv for argv in captured) + assert svc.read_toolchain_status(ws) == ("3.14", "ok") + + +@pytest.mark.asyncio +async def test_records_broken_on_collection_error(tmp_path: Path) -> None: + ws = _make_workspace(tmp_path) + (ws / "pyproject.toml").write_text(_PY) + (ws / "uv.lock").write_text("version = 1\n") + svc = _service() + captured: list[list[str]] = [] + + with ( + patch("roboco.services.workspace.settings.toolchain_match_enabled", True), + patch( + "roboco.services.workspace.subprocess.run", + side_effect=_fake_run_factory( + captured, collect_rc=2 + ), # pytest collect error + ), + patch("roboco.services.workspace._ensure_agent_owned"), + ): + await svc.install_dev_deps(ws) + + assert svc.read_toolchain_status(ws) == ("3.14", "broken") + + +@pytest.mark.asyncio +async def test_flag_off_is_unchanged_no_marker(tmp_path: Path) -> None: + ws = _make_workspace(tmp_path) + (ws / "pyproject.toml").write_text(_PY) + (ws / "uv.lock").write_text("version = 1\n") + svc = _service() + captured: list[list[str]] = [] + + with ( + patch("roboco.services.workspace.settings.toolchain_match_enabled", False), + patch( + "roboco.services.workspace.subprocess.run", + side_effect=_fake_run_factory(captured, collect_rc=0), + ), + patch("roboco.services.workspace._ensure_agent_owned"), + ): + await svc.install_dev_deps(ws) + + assert ["uv", "sync", "--extra", "dev"] in captured + assert not any("--python" in argv for argv in captured) + assert not any("--collect-only" in argv for argv in captured) + assert svc.read_toolchain_status(ws) == (None, None)