From ea1f608e79d8809ff4d7d2428972fdb633a01918 Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Sat, 9 May 2026 15:58:27 -0700 Subject: [PATCH] fix: make proxy upgrades version-aware Derive source-tree versions from release history so headroom --version no longer reports stale project metadata. Restart stale idle proxies after an upgrade, but leave active sessions running to avoid interrupting ongoing conversations. Remove _version.py from version-sync ownership and make version verification catch package/plugin manifest drift. --- headroom/_version.py | 60 +++++++++- headroom/cli/wrap.py | 156 ++++++++++++++++++++++++- scripts/tests/test_version_sync.py | 12 +- scripts/verify-versions.py | 48 +++++--- scripts/version-sync.py | 13 --- tests/test_cli/test_wrap_persistent.py | 112 ++++++++++++++++++ tests/test_package_init_lazy.py | 34 ++++++ 7 files changed, 399 insertions(+), 36 deletions(-) diff --git a/headroom/_version.py b/headroom/_version.py index 07d29900a..cbb479b14 100644 --- a/headroom/_version.py +++ b/headroom/_version.py @@ -1,3 +1,61 @@ """Package version metadata.""" -__version__ = "0.9.1" +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path + +UNKNOWN_VERSION = "unknown" + + +def _source_root() -> Path | None: + """Return the repository root when imported from a git checkout.""" + root = Path(__file__).resolve().parents[1] + if (root / ".git").exists() and (root / "pyproject.toml").exists(): + return root + return None + + +def _source_tree_version(root: Path) -> str | None: + """Compute the version release automation would assign to this checkout.""" + try: + from headroom.release_version import ( + compute_release_version, + determine_bump_level, + get_canonical_version, + list_release_commits, + list_release_tags, + ) + + tags = list_release_tags(root) + previous_tag = compute_release_version( + canonical_version=get_canonical_version(root), + level="patch", + tags=tags, + ).previous_tag + commits = list_release_commits(root, previous_tag) + level = determine_bump_level(commits) + return compute_release_version( + canonical_version=get_canonical_version(root), + level=level, + tags=tags, + ).version + except Exception: + return None + + +def get_version() -> str: + """Return Headroom's runtime version.""" + root = _source_root() + if root is not None: + source_version = _source_tree_version(root) + if source_version: + return source_version + + try: + return version("headroom-ai") + except PackageNotFoundError: + return UNKNOWN_VERSION + + +__version__ = get_version() diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index fc0eb0c2a..93fec93a7 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -34,6 +34,7 @@ if sys.platform == "win32" and hasattr(sys.stdout, "buffer"): import click +from headroom._version import __version__ as _HEADROOM_VERSION from headroom.copilot_auth import DEFAULT_API_URL as COPILOT_API_URL from headroom.copilot_auth import has_oauth_auth, resolve_client_bearer_token from headroom.providers.aider import build_launch_env as _build_aider_launch_env @@ -882,6 +883,65 @@ def _query_proxy_config(port: int) -> dict[str, Any] | None: return _copilot_query_proxy_config(port) +def _query_proxy_health(port: int) -> dict[str, Any] | None: + """Query the running proxy's full /health payload.""" + import urllib.error + import urllib.request + + url = f"http://127.0.0.1:{port}/health" + try: + with urllib.request.urlopen(url, timeout=2) as response: + payload = json.loads(response.read().decode("utf-8")) + except (OSError, urllib.error.URLError, ValueError, json.JSONDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +def _proxy_health_config(payload: dict[str, Any] | None) -> dict[str, Any] | None: + """Extract the config block from a Headroom /health payload.""" + if payload is None: + return None + config = payload.get("config") + return config if isinstance(config, dict) else None + + +def _proxy_active_session_count(payload: dict[str, Any] | None) -> int: + """Return active session count from /health runtime metadata.""" + if payload is None: + return 0 + runtime = payload.get("runtime") + if not isinstance(runtime, dict): + return 0 + websocket_sessions = runtime.get("websocket_sessions") + if not isinstance(websocket_sessions, dict): + return 0 + counts = [] + for key in ("active_sessions", "active_relay_tasks"): + value = websocket_sessions.get(key, 0) + if isinstance(value, int): + counts.append(value) + return max(counts, default=0) + + +def _proxy_version(payload: dict[str, Any] | None) -> str | None: + """Return the running proxy version when it exposes one.""" + if payload is None: + return None + version = payload.get("version") + return version if isinstance(version, str) and version else None + + +def _proxy_needs_version_restart(payload: dict[str, Any] | None) -> bool: + """Return True when a running Headroom proxy uses a different package version.""" + running_version = _proxy_version(payload) + return ( + running_version is not None + and running_version != "unknown" + and _HEADROOM_VERSION != "unknown" + and running_version != _HEADROOM_VERSION + ) + + def _detect_running_proxy_backend(port: int) -> str | None: """Read the backend of an already-running proxy from its health endpoint.""" return _copilot_detect_running_proxy_backend(port) @@ -1024,6 +1084,46 @@ def _recover_persistent_proxy(port: int) -> bool: return False +def _restart_persistent_proxy(manifest: Any, port: int) -> bool: + """Restart a persistent deployment after an idle stale-version detection.""" + from headroom.install.models import InstallPreset, SupervisorKind + from headroom.install.runtime import ( + start_detached_agent, + start_persistent_docker, + stop_runtime, + wait_ready, + ) + from headroom.install.supervisors import start_supervisor + + click.echo( + f" Restarting persistent deployment '{manifest.profile}' " + f"with Headroom {_HEADROOM_VERSION}..." + ) + try: + if manifest.preset == InstallPreset.PERSISTENT_DOCKER.value: + stop_runtime(manifest) + start_persistent_docker(manifest) + elif manifest.supervisor_kind == SupervisorKind.SERVICE.value: + # start_supervisor performs the platform-native restart operation: + # systemd restart, launchctl kickstart -k, or sc.exe start. + start_supervisor(manifest) + else: + stop_runtime(manifest) + start_detached_agent(manifest.profile) + except Exception as exc: + click.echo( + f" Warning: could not restart persistent deployment '{manifest.profile}': {exc}" + ) + return False + + if wait_ready(manifest, timeout_seconds=45): + click.echo(f" Restarted persistent deployment '{manifest.profile}' on port {port}") + return True + + click.echo(f" Warning: persistent deployment '{manifest.profile}' did not become ready") + return False + + def _copilot_model_configured(copilot_args: tuple[str, ...], env: dict[str, str]) -> bool: """Return True when Copilot BYOK model selection is configured.""" return _copilot_model_configured_impl(copilot_args, env) @@ -1069,6 +1169,26 @@ def _ensure_proxy( from headroom.install.health import probe_ready if probe_ready(manifest.health_url): + health_payload = helpers._query_proxy_health(port) + if helpers._proxy_needs_version_restart(health_payload): + running_version = helpers._proxy_version(health_payload) or "unknown" + active_sessions = helpers._proxy_active_session_count(health_payload) + if active_sessions > 0: + click.echo( + f" Proxy on port {port} is running Headroom {running_version}; " + f"current CLI is {_HEADROOM_VERSION}." + ) + click.echo( + f" Leaving it running because {active_sessions} active session(s) " + "are still attached; it will be restarted when idle." + ) + return None + if helpers._restart_persistent_proxy(manifest, port): + return None + raise click.ClickException( + f"Persistent deployment '{manifest.profile}' on port {port} " + f"is running stale Headroom {running_version} and could not be restarted." + ) click.echo(f" Proxy already running on port {port}") return None if helpers._recover_persistent_proxy(port): @@ -1085,7 +1205,41 @@ def _ensure_proxy( if helpers._check_proxy(port): # Proxy is running — check if it has the features we need needs_restart = False - running_config = helpers._query_proxy_config(port) + health_payload = helpers._query_proxy_health(port) + running_config = helpers._proxy_health_config(health_payload) + if running_config is None: + running_config = helpers._query_proxy_config(port) + + if helpers._proxy_needs_version_restart(health_payload): + running_version = helpers._proxy_version(health_payload) or "unknown" + active_sessions = helpers._proxy_active_session_count(health_payload) + if active_sessions > 0: + click.echo( + f" Proxy on port {port} is running Headroom {running_version}; " + f"current CLI is {_HEADROOM_VERSION}." + ) + click.echo( + f" Leaving it running because {active_sessions} active session(s) " + "are still attached; it will be restarted when idle." + ) + return None + + click.echo( + f" Proxy on port {port} is running Headroom {running_version}; " + f"restarting with {_HEADROOM_VERSION}..." + ) + proxy_pid = running_config.get("pid") if running_config is not None else None + if proxy_pid is None: + raise click.ClickException( + f"Proxy on port {port} is stale but did not expose a PID. " + "Stop it manually and retry." + ) + if not helpers._kill_proxy_by_pid(int(proxy_pid), port): + raise click.ClickException( + f"Failed to stop stale proxy (PID {proxy_pid}) on port {port}. " + "Stop it manually and retry." + ) + needs_restart = True if running_config is not None: missing = [] diff --git a/scripts/tests/test_version_sync.py b/scripts/tests/test_version_sync.py index ebecb8439..d1f39b51f 100644 --- a/scripts/tests/test_version_sync.py +++ b/scripts/tests/test_version_sync.py @@ -34,7 +34,7 @@ def temp_project(tmp_path: Path) -> dict[str, Path]: pyproject = root / "pyproject.toml" pyproject.write_text('[project]\nversion = "0.5.25"\n') - # headroom/_version.py + # headroom/_version.py is runtime-derived and must not be rewritten by version-sync. version_py = headroom / "_version.py" version_py.write_text('"""Package version metadata."""\n\n__version__ = "0.5.25"\n') @@ -102,9 +102,9 @@ def test_version_sync_explicit_version(temp_project: dict[str, Path]) -> None: pyproject_content = temp_project["pyproject"].read_text() assert 'version = "0.7.0"' in pyproject_content - # Verify headroom/_version.py + # Verify headroom/_version.py is not a synced manifest. version_py_content = temp_project["version_py"].read_text() - assert '__version__ = "0.7.0"' in version_py_content + assert '__version__ = "0.5.25"' in version_py_content # Verify plugins/openclaw/package.json openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text()) @@ -157,7 +157,7 @@ def test_bump_patch(temp_project: dict[str, Path]) -> None: assert 'version = "0.5.26"' in pyproject_content version_py_content = temp_project["version_py"].read_text() - assert '__version__ = "0.5.26"' in version_py_content + assert '__version__ = "0.5.25"' in version_py_content openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text()) assert openclaw_pkg["version"] == "0.5.26" @@ -187,7 +187,7 @@ def test_bump_minor(temp_project: dict[str, Path]) -> None: assert 'version = "0.6.0"' in pyproject_content version_py_content = temp_project["version_py"].read_text() - assert '__version__ = "0.6.0"' in version_py_content + assert '__version__ = "0.5.25"' in version_py_content openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text()) assert openclaw_pkg["version"] == "0.6.0" @@ -217,7 +217,7 @@ def test_bump_major(temp_project: dict[str, Path]) -> None: assert 'version = "1.0.0"' in pyproject_content version_py_content = temp_project["version_py"].read_text() - assert '__version__ = "1.0.0"' in version_py_content + assert '__version__ = "0.5.25"' in version_py_content openclaw_pkg = json.loads(temp_project["openclaw_pkg"].read_text()) assert openclaw_pkg["version"] == "1.0.0" diff --git a/scripts/verify-versions.py b/scripts/verify-versions.py index a234781db..5e7e92987 100644 --- a/scripts/verify-versions.py +++ b/scripts/verify-versions.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Verify all package versions are in sync before publishing.""" +"""Verify all package manifest versions are in sync before publishing.""" import json from pathlib import Path @@ -12,26 +12,44 @@ except ImportError: # pragma: no cover - Python 3.10 fallback ROOT = Path(__file__).parent.parent -def main(): +def _read_json_version(path: Path) -> str: + with open(path, encoding="utf-8") as f: + return str(json.load(f)["version"]) + + +def _read_marketplace_versions(path: Path) -> dict[str, str]: + with open(path, encoding="utf-8") as f: + payload = json.load(f) + + versions: dict[str, str] = {} + metadata = payload.get("metadata") + if isinstance(metadata, dict): + versions[f"{path}:metadata"] = str(metadata.get("version")) + plugins = payload.get("plugins") + if isinstance(plugins, list): + for index, plugin in enumerate(plugins): + if isinstance(plugin, dict): + versions[f"{path}:plugins[{index}]"] = str(plugin.get("version")) + return versions + + +def main() -> None: with open(ROOT / "pyproject.toml", "rb") as f: py_ver = tomllib.load(f)["project"]["version"] - with open(ROOT / "plugins/openclaw/package.json") as f: - npm_openclaw_ver = json.load(f)["version"] - - with open(ROOT / "sdk/typescript/package.json") as f: - npm_sdk_ver = json.load(f)["version"] - - headroom_ver = ( - (ROOT / "headroom" / "_version.py").read_text().split('__version__ = "')[1].split('"')[0] - ) - versions = { "pyproject.toml": py_ver, - "openclaw/package.json": npm_openclaw_ver, - "typescript/package.json": npm_sdk_ver, - "headroom/_version.py": headroom_ver, + "plugins/openclaw/package.json": _read_json_version(ROOT / "plugins/openclaw/package.json"), + "sdk/typescript/package.json": _read_json_version(ROOT / "sdk/typescript/package.json"), + "plugins/headroom-agent-hooks/.claude-plugin/plugin.json": _read_json_version( + ROOT / "plugins/headroom-agent-hooks/.claude-plugin/plugin.json" + ), + "plugins/headroom-agent-hooks/.github/plugin/plugin.json": _read_json_version( + ROOT / "plugins/headroom-agent-hooks/.github/plugin/plugin.json" + ), } + versions.update(_read_marketplace_versions(ROOT / ".claude-plugin/marketplace.json")) + versions.update(_read_marketplace_versions(ROOT / ".github/plugin/marketplace.json")) if not all(v == py_ver for v in versions.values()): print("Version mismatch detected:") diff --git a/scripts/version-sync.py b/scripts/version-sync.py index 741c47dcf..1a404718f 100644 --- a/scripts/version-sync.py +++ b/scripts/version-sync.py @@ -37,18 +37,6 @@ def bump_version(version: str, bump_type: str) -> str: return f"{major}.{minor}.{patch}" -def update_version_py(root: Path, version: str) -> None: - """Update headroom/_version.py with new version.""" - version_py_path = root / "headroom" / "_version.py" - content = version_py_path.read_text(encoding="utf-8") - updated = re.sub( - r'__version__ = "[^"]+"', - f'__version__ = "{version}"', - content, - ) - version_py_path.write_text(updated, encoding="utf-8") - - def update_package_json(file_path: Path, version: str) -> None: """Update a package.json version field.""" with open(file_path, encoding="utf-8") as f: @@ -178,7 +166,6 @@ def main() -> None: # Update all versioned files update_pyproject_version(args.root, version) - update_version_py(args.root, version) update_openclaw_package_json( args.root / "plugins" / "openclaw" / "package.json", version, version ) diff --git a/tests/test_cli/test_wrap_persistent.py b/tests/test_cli/test_wrap_persistent.py index ddc574668..86dd22786 100644 --- a/tests/test_cli/test_wrap_persistent.py +++ b/tests/test_cli/test_wrap_persistent.py @@ -88,6 +88,59 @@ def test_ensure_proxy_falls_back_when_persistent_manifest_is_stale(monkeypatch) assert calls == ["start"] +def test_ensure_proxy_restarts_idle_stale_persistent_deployment(monkeypatch) -> None: + calls: list[str] = [] + health = { + "version": "0.0.1", + "runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}}, + "config": {"pid": 12345}, + } + + monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest()) + monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True) + monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health) + monkeypatch.setattr( + wrap_cli, + "_restart_persistent_proxy", + lambda manifest, port: calls.append(f"restart:{manifest.profile}:{port}") or True, + ) + monkeypatch.setattr( + wrap_cli, + "_start_proxy", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("ephemeral proxy should not start") + ), + ) + + result = wrap_cli._ensure_proxy(8787, False) + + assert result is None + assert calls == ["restart:default:8787"] + + +def test_ensure_proxy_leaves_active_stale_persistent_deployment_running(monkeypatch) -> None: + health = { + "version": "0.0.1", + "runtime": {"websocket_sessions": {"active_sessions": 1, "active_relay_tasks": 2}}, + "config": {"pid": 12345}, + } + + monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: _Manifest()) + monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: True) + monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health) + monkeypatch.setattr( + wrap_cli, + "_restart_persistent_proxy", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("active deployment should not restart") + ), + ) + + result = wrap_cli._ensure_proxy(8787, False) + + assert result is None + + def test_find_persistent_manifest_prefers_default_profile(monkeypatch) -> None: class DefaultManifest: profile = "default" @@ -122,3 +175,62 @@ def test_recover_persistent_proxy_warns_for_task_deployment(monkeypatch) -> None monkeypatch.setattr("headroom.install.health.probe_ready", lambda url: False) assert wrap_cli._recover_persistent_proxy(8787) is False + + +def test_ensure_proxy_restarts_idle_stale_ephemeral_proxy(monkeypatch) -> None: + calls: list[object] = [] + health = { + "version": "0.0.1", + "runtime": {"websocket_sessions": {"active_sessions": 0, "active_relay_tasks": 0}}, + "config": {"pid": "12345", "memory": False, "learn": False, "code_graph": False}, + } + + monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None) + monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: len(calls) == 0) + monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health) + monkeypatch.setattr( + wrap_cli, + "_kill_proxy_by_pid", + lambda pid, port: calls.append(("kill", pid, port)) or True, + ) + monkeypatch.setattr( + wrap_cli, + "_start_proxy", + lambda *args, **kwargs: calls.append(("start", args, kwargs)), + ) + + result = wrap_cli._ensure_proxy(8787, False) + + assert result is None + assert calls[0] == ("kill", 12345, 8787) + assert calls[1][0] == "start" + + +def test_ensure_proxy_leaves_active_stale_ephemeral_proxy_running(monkeypatch) -> None: + health = { + "version": "0.0.1", + "runtime": {"websocket_sessions": {"active_sessions": 2, "active_relay_tasks": 2}}, + "config": {"pid": "12345", "memory": False, "learn": False, "code_graph": False}, + } + + monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None) + monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: True) + monkeypatch.setattr(wrap_cli, "_query_proxy_health", lambda port: health) + monkeypatch.setattr( + wrap_cli, + "_kill_proxy_by_pid", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("active proxy should not be killed") + ), + ) + monkeypatch.setattr( + wrap_cli, + "_start_proxy", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("replacement proxy should not start") + ), + ) + + result = wrap_cli._ensure_proxy(8787, False) + + assert result is None diff --git a/tests/test_package_init_lazy.py b/tests/test_package_init_lazy.py index 7c9203405..72445f908 100644 --- a/tests/test_package_init_lazy.py +++ b/tests/test_package_init_lazy.py @@ -6,6 +6,11 @@ import json import subprocess import sys import textwrap +from importlib.metadata import PackageNotFoundError +from pathlib import Path +from unittest.mock import patch + +import headroom._version as version_module def test_headroom_import_stays_lazy() -> None: @@ -40,6 +45,35 @@ def test_headroom_import_stays_lazy() -> None: assert data["memory_loaded"] is False +def test_version_prefers_installed_distribution_metadata() -> None: + with ( + patch.object(version_module, "_source_root", return_value=None), + patch.object(version_module, "version", return_value="9.8.7") as package_version, + ): + assert version_module.get_version() == "9.8.7" + + package_version.assert_called_once_with("headroom-ai") + + +def test_version_reports_unknown_when_distribution_metadata_is_missing() -> None: + with ( + patch.object(version_module, "_source_root", return_value=None), + patch.object(version_module, "version", side_effect=PackageNotFoundError), + ): + assert version_module.get_version() == version_module.UNKNOWN_VERSION + + +def test_version_prefers_source_tree_release_history() -> None: + with ( + patch.object(version_module, "_source_root", return_value=Path(".")), + patch.object(version_module, "_source_tree_version", return_value="0.21.17"), + patch.object(version_module, "version", return_value="0.9.1") as package_version, + ): + assert version_module.get_version() == "0.21.17" + + package_version.assert_not_called() + + def test_proxy_server_import_skips_litellm_backend() -> None: script = textwrap.dedent( """