From 9cdae9afec12d7a00f58bca4d65e82ce2dcc1921 Mon Sep 17 00:00:00 2001 From: rabbitblood Date: Thu, 16 Apr 2026 07:53:03 -0700 Subject: [PATCH] fix: switch top-level `from adapters import` to absolute imports (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every modular workspace template repo (claude-code, hermes, langgraph, …) was crashing on boot with: KeyError: "Unknown runtime ''. Available: " Root cause: `molecule_runtime/main.py` and four other modules used top-level imports like `from adapters import get_adapter` — a monorepo legacy that resolved when something on sys.path had an `adapters/` package. Standalone template repos COPY only `adapter.py` (singular) to /app and don't ship an `adapters/` package, so this import path went through some side-resolution that left `get_adapter` unable to see the user's adapter. The ADAPTER_MODULE → import → getattr → issubclass chain then silently fell through to the discovery branch and reported "Unknown runtime". Fix is one-line per file: `from adapters` → `from molecule_runtime.adapters` in: - molecule_runtime/main.py:27 - molecule_runtime/a2a_executor.py:44 - molecule_runtime/coordinator.py:20 - molecule_runtime/prompt.py:6 - molecule_runtime/builtin_tools/temporal_workflow.py:417 Tests + CI added so this regression class is caught at PR time, not at runtime in self-hosters' clusters: - tests/test_imports.py: parametrised import smoke for every previously affected module + a grep guard that fails if any future change reintroduces a top-level `from adapters` / `import adapters` line - .github/workflows/ci.yml: runs the smoke on every PR (no CI existed before — the publish workflow only fires on tag push) Closes #1. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 29 ++++++++ molecule_runtime/a2a_executor.py | 2 +- .../builtin_tools/temporal_workflow.py | 2 +- molecule_runtime/coordinator.py | 2 +- molecule_runtime/main.py | 2 +- molecule_runtime/prompt.py | 2 +- tests/test_imports.py | 73 +++++++++++++++++++ 7 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/test_imports.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c2895da --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install package + test deps + run: | + pip install -e . + pip install pytest + + - name: Run import smoke tests + # Critical: these tests run in an environment with NO top-level + # `adapters/` package on sys.path. They catch the regression that + # broke every modular workspace template repo before the absolute- + # import fix. Do not weaken — the failure mode (silent fallthrough + # in get_adapter → "Unknown runtime") is hard to debug at runtime. + run: pytest tests/ -v diff --git a/molecule_runtime/a2a_executor.py b/molecule_runtime/a2a_executor.py index ebe4008..444a84a 100644 --- a/molecule_runtime/a2a_executor.py +++ b/molecule_runtime/a2a_executor.py @@ -41,7 +41,7 @@ from a2a.server.events import EventQueue from a2a.server.tasks import TaskUpdater from a2a.types import Part, TextPart from a2a.utils import new_agent_text_message -from adapters.shared_runtime import ( +from molecule_runtime.adapters.shared_runtime import ( extract_history as _extract_history, extract_message_text, brief_task, diff --git a/molecule_runtime/builtin_tools/temporal_workflow.py b/molecule_runtime/builtin_tools/temporal_workflow.py index bb5c049..6b1c400 100644 --- a/molecule_runtime/builtin_tools/temporal_workflow.py +++ b/molecule_runtime/builtin_tools/temporal_workflow.py @@ -414,7 +414,7 @@ class TemporalWorkflowWrapper: # Build serialisable AgentTaskInput try: - from adapters.shared_runtime import ( + from molecule_runtime.adapters.shared_runtime import ( extract_history as _extract_history, extract_message_text, ) diff --git a/molecule_runtime/coordinator.py b/molecule_runtime/coordinator.py index 99e9adb..5c4a3d1 100644 --- a/molecule_runtime/coordinator.py +++ b/molecule_runtime/coordinator.py @@ -17,7 +17,7 @@ import os import httpx from langchain_core.tools import tool -from adapters.shared_runtime import build_peer_section +from molecule_runtime.adapters.shared_runtime import build_peer_section from policies.routing import build_team_routing_payload logger = logging.getLogger(__name__) diff --git a/molecule_runtime/main.py b/molecule_runtime/main.py index 4ff54e7..dbc8c61 100644 --- a/molecule_runtime/main.py +++ b/molecule_runtime/main.py @@ -24,7 +24,7 @@ from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore from a2a.types import AgentCard, AgentCapabilities, AgentSkill -from adapters import get_adapter, AdapterConfig +from molecule_runtime.adapters import get_adapter, AdapterConfig from config import load_config from heartbeat import HeartbeatLoop from preflight import run_preflight, render_preflight_report diff --git a/molecule_runtime/prompt.py b/molecule_runtime/prompt.py index a9876d4..f488420 100644 --- a/molecule_runtime/prompt.py +++ b/molecule_runtime/prompt.py @@ -3,7 +3,7 @@ from pathlib import Path from skill_loader.loader import LoadedSkill -from adapters.shared_runtime import build_peer_section +from molecule_runtime.adapters.shared_runtime import build_peer_section DEFAULT_MEMORY_SNAPSHOT_FILES = ("MEMORY.md", "USER.md") diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 0000000..2541d80 --- /dev/null +++ b/tests/test_imports.py @@ -0,0 +1,73 @@ +"""Smoke tests for module imports. + +Catches the class of bug fixed by the absolute-import switch +(monorepo legacy `from adapters import …` worked when something on +sys.path had an `adapters/` package; modular template repos that ship +only `/app/adapter.py` broke). Any future regression to relative-style +top-level imports gets caught here before publish. + +These tests run in a clean Python environment with NO `adapters/` +package on sys.path — exactly the deployment shape consumers see. +""" + +import importlib +import sys + +import pytest + + +# Every module that previously had `from adapters import …` or +# `from adapters.… import …`. Importing any of them must succeed +# without an `adapters/` package on sys.path. +MODULES = [ + "molecule_runtime", + "molecule_runtime.adapters", + "molecule_runtime.adapters.base", + "molecule_runtime.main", + "molecule_runtime.a2a_executor", + "molecule_runtime.coordinator", + "molecule_runtime.prompt", + "molecule_runtime.builtin_tools.temporal_workflow", +] + + +@pytest.mark.parametrize("module_name", MODULES) +def test_module_imports_without_top_level_adapters_pkg(module_name): + # Sanity: no top-level `adapters` package shadowing molecule_runtime.adapters. + assert "adapters" not in sys.modules or sys.modules["adapters"].__name__ != "adapters", ( + "test environment must not have a top-level `adapters` package — " + "this test catches the regression of importing `from adapters import …` " + "instead of `from molecule_runtime.adapters import …`" + ) + mod = importlib.import_module(module_name) + assert mod is not None + # Re-import via importlib should be idempotent. + mod2 = importlib.import_module(module_name) + assert mod is mod2 + + +def test_get_adapter_resolves_via_absolute_path(): + from molecule_runtime.adapters import get_adapter + assert callable(get_adapter) + + +def test_no_top_level_adapters_imports_remain(): + """Grep guard: keep the import-style invariant explicit so a future + drive-by change doesn't silently reintroduce `from adapters import`.""" + import os + pkg_dir = os.path.dirname(importlib.import_module("molecule_runtime").__file__) + offenders = [] + for dirpath, _, files in os.walk(pkg_dir): + for fname in files: + if not fname.endswith(".py"): + continue + path = os.path.join(dirpath, fname) + with open(path, "r", encoding="utf-8") as fh: + for lineno, line in enumerate(fh, start=1): + stripped = line.lstrip() + if stripped.startswith("from adapters") or stripped.startswith("import adapters"): + offenders.append(f"{path}:{lineno}: {line.rstrip()}") + assert not offenders, ( + "Top-level `adapters` imports found — must be `from molecule_runtime.adapters …`:\n " + + "\n ".join(offenders) + )